332 lines
11 KiB
TypeScript
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';
|
|
}
|