ynab-amazon-helper/frontend.tsx
2025-11-04 12:27:02 -05:00

332 lines
11 KiB
TypeScript

import type { TransactionMatch, YNABCategory, APIResponse } from './types';
let currentSessionId: string | null = null;
let matches: TransactionMatch[] = [];
let categories: YNABCategory[] = [];
// Initialize date inputs with default values (last 30 days)
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const startDateInput = document.getElementById('startDate') as HTMLInputElement;
const endDateInput = document.getElementById('endDate') as HTMLInputElement;
const useUnapprovedCheckbox = document.getElementById('useUnapproved') as HTMLInputElement;
const dateRangeSection = document.getElementById('dateRangeSection') as HTMLDivElement;
startDateInput.value = startDate.toISOString().split('T')[0];
endDateInput.value = endDate.toISOString().split('T')[0];
// Fetch categories on page load
fetchCategories();
// Event listeners
document.getElementById('fetchBtn')?.addEventListener('click', fetchMatches);
document.getElementById('approveBtn')?.addEventListener('click', approveMatches);
// Toggle date range visibility based on checkbox
useUnapprovedCheckbox.addEventListener('change', () => {
if (useUnapprovedCheckbox.checked) {
dateRangeSection.style.display = 'none';
} else {
dateRangeSection.style.display = 'block';
}
});
async function fetchCategories() {
try {
const response = await fetch('/api/categories');
const data: APIResponse<YNABCategory[]> = await response.json();
if (data.success && data.data) {
categories = data.data;
console.log(`Loaded ${categories.length} categories`);
}
} catch (error) {
console.error('Error fetching categories:', error);
}
}
async function fetchMatches() {
const useUnapproved = useUnapprovedCheckbox.checked;
const startDate = (document.getElementById('startDate') as HTMLInputElement).value;
const endDate = (document.getElementById('endDate') as HTMLInputElement).value;
// Validate inputs
if (!useUnapproved && (!startDate || !endDate)) {
showError('Please select both start and end dates or use unapproved transactions');
return;
}
// Show loading state
showSection('loading');
hideError();
try {
const body: any = { useUnapproved };
if (!useUnapproved) {
body.startDate = startDate;
body.endDate = endDate;
}
const response = await fetch('/api/fetch-matches', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data: APIResponse<{ sessionId: string; stats: any }> = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch matches');
}
currentSessionId = data.data!.sessionId;
// Display statistics
displayStats(data.data!.stats);
// Fetch the matches
await loadMatches();
// Show matches section
showSection('matches');
} catch (error) {
showSection('error');
showError(error instanceof Error ? error.message : 'An error occurred');
}
}
async function loadMatches() {
if (!currentSessionId) return;
try {
const response = await fetch(`/api/matches/${currentSessionId}`);
const data: APIResponse<TransactionMatch[]> = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load matches');
}
matches = data.data!;
displayMatches();
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to load matches');
}
}
function displayStats(stats: any) {
const statsContainer = document.getElementById('stats')!;
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-label">Total Transactions</div>
<div class="stat-value">${stats.total}</div>
</div>
<div class="stat-card">
<div class="stat-label">Matched</div>
<div class="stat-value">${stats.matched}</div>
</div>
<div class="stat-card">
<div class="stat-label">High Confidence</div>
<div class="stat-value">${stats.highConfidence}</div>
</div>
<div class="stat-card">
<div class="stat-label">Medium Confidence</div>
<div class="stat-value">${stats.mediumConfidence}</div>
</div>
<div class="stat-card">
<div class="stat-label">Low Confidence</div>
<div class="stat-value">${stats.lowConfidence}</div>
</div>
`;
}
function displayMatches() {
const matchesList = document.getElementById('matchesList')!;
matchesList.innerHTML = '';
matches.forEach((match, index) => {
const card = createMatchCard(match, index);
matchesList.appendChild(card);
});
}
function createMatchCard(match: TransactionMatch, index: number): HTMLDivElement {
const card = document.createElement('div');
card.className = `match-card ${match.status === 'approved' ? 'status-approved' : ''} ${match.status === 'rejected' ? 'status-rejected' : ''} ${!match.amazonOrder ? 'no-match' : ''}`;
const amount = Math.abs(match.ynabTransaction.amount / 1000);
const confidenceClass =
match.matchConfidence >= 0.8
? 'confidence-high'
: match.matchConfidence >= 0.5
? 'confidence-medium'
: 'confidence-low';
const confidencePercent = Math.round(match.matchConfidence * 100);
card.innerHTML = `
<div class="match-header">
<div class="match-info">
<div class="match-date">${match.ynabTransaction.date}${match.ynabTransaction.account_name}</div>
<div class="match-payee">${match.ynabTransaction.payee_name || 'Unknown Payee'}</div>
<div class="match-amount">$${amount.toFixed(2)}</div>
${match.ynabTransaction.memo ? `<div style="font-size: 0.9rem; color: #64748b; margin-top: 5px;">${match.ynabTransaction.memo}</div>` : ''}
</div>
${match.amazonOrder ? `<div class="confidence-badge ${confidenceClass}">${confidencePercent}% Match</div>` : '<div class="confidence-badge confidence-low">No Match</div>'}
</div>
${match.amazonOrder ? `
<div class="amazon-order">
<div class="order-header">Amazon Order #${match.amazonOrder.orderId}</div>
<div class="order-items">
${match.amazonOrder.items.map(item => `${item.title} (${item.quantity}x $${item.price.toFixed(2)})`).join('<br>')}
</div>
<div>Total: $${match.amazonOrder.total.toFixed(2)}</div>
<a href="${match.amazonOrder.orderUrl}" target="_blank" class="order-link">View on Amazon →</a>
</div>
<div class="category-select">
<label style="font-size: 0.9rem; margin-bottom: 8px; display: block; color: #334155;">
<strong>Category:</strong> ${match.suggestedCategory ? '✨ AI Suggested' : 'Select manually'}
</label>
<select id="category-${index}">
<option value="">-- No Category Change --</option>
${renderCategoryOptions(match.suggestedCategory?.id)}
</select>
</div>
<div class="match-actions">
<button class="btn-small btn-approve" onclick="updateMatchStatus(${index}, 'approved')">
${match.status === 'approved' ? '✓ Approved' : 'Approve'}
</button>
<button class="btn-small btn-reject" onclick="updateMatchStatus(${index}, 'rejected')">
${match.status === 'rejected' ? '✗ Rejected' : 'Reject'}
</button>
</div>
` : `
<div style="color: #64748b; font-style: italic; margin-top: 10px;">
No matching Amazon order found within the date and amount tolerance.
</div>
`}
`;
return card;
}
function renderCategoryOptions(suggestedId?: string): string {
const grouped = new Map<string, YNABCategory[]>();
categories.forEach((cat) => {
if (!grouped.has(cat.category_group_name)) {
grouped.set(cat.category_group_name, []);
}
grouped.get(cat.category_group_name)!.push(cat);
});
let html = '';
grouped.forEach((cats, groupName) => {
html += `<optgroup label="${groupName}">`;
cats.forEach((cat) => {
const selected = cat.id === suggestedId ? 'selected' : '';
html += `<option value="${cat.id}" ${selected}>${cat.name}</option>`;
});
html += '</optgroup>';
});
return html;
}
(window as any).updateMatchStatus = async (index: number, status: 'approved' | 'rejected') => {
if (!currentSessionId) return;
// Get the selected category
const categorySelect = document.getElementById(`category-${index}`) as HTMLSelectElement;
const categoryId = categorySelect?.value || null;
const suggestedCategory = categoryId
? categories.find((c) => c.id === categoryId) || null
: null;
try {
const response = await fetch(`/api/matches/${currentSessionId}/${index}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status, suggestedCategory }),
});
const data: APIResponse<TransactionMatch> = await response.json();
if (data.success && data.data) {
matches[index] = data.data;
displayMatches();
}
} catch (error) {
console.error('Error updating match status:', error);
showError('Failed to update match status');
}
};
async function approveMatches() {
if (!currentSessionId) return;
const approvedCount = matches.filter((m) => m.status === 'approved').length;
if (approvedCount === 0) {
showError('No matches approved. Please approve at least one match before applying.');
return;
}
if (!confirm(`Apply ${approvedCount} approved match(es) to YNAB?`)) {
return;
}
showSection('loading');
try {
const response = await fetch(`/api/approve-matches/${currentSessionId}`, {
method: 'POST',
});
const data: APIResponse<any[]> = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to apply matches');
}
const results = data.data!;
const successCount = results.filter((r) => r.success).length;
const failCount = results.length - successCount;
alert(
`Successfully updated ${successCount} transaction(s)!${
failCount > 0 ? `\n${failCount} failed.` : ''
}`
);
showSection('matches');
} catch (error) {
showSection('matches');
showError(error instanceof Error ? error.message : 'Failed to apply matches');
}
}
function showSection(section: 'loading' | 'error' | 'matches' | 'stats') {
document.getElementById('loadingSection')!.style.display = section === 'loading' ? 'block' : 'none';
document.getElementById('errorSection')!.style.display = section === 'error' ? 'block' : 'none';
document.getElementById('matchesSection')!.style.display = section === 'matches' ? 'block' : 'none';
document.getElementById('statsSection')!.style.display = section === 'stats' || section === 'matches' ? 'block' : 'none';
}
function showError(message: string) {
const errorMessage = document.getElementById('errorMessage')!;
errorMessage.textContent = message;
document.getElementById('errorSection')!.style.display = 'block';
}
function hideError() {
document.getElementById('errorSection')!.style.display = 'none';
}