1
2
3
4
5(function () {
6 'use strict';
7
8
9 const state = {
10 transactions: [],
11 budgets: {},
12 filters: {
13 search: '',
14 category: 'All',
15 type: 'All',
16 startDate: '',
17 endDate: ''
18 },
19 chartGroupMode: 'daily',
20 theme: 'dark',
21 editingTxId: null,
22 spendingChart: null,
23 donutChart: null
24 };
25
26 const service = FinanceData.service;
27 const categories = FinanceData.DEFAULT_CATEGORIES;
28
29
30 let el = {};
31
32 function cacheDOMElements() {
33 el = {
34 html: document.documentElement,
35
36 kpiIncome: document.getElementById('kpi-income'),
37 kpiExpense: document.getElementById('kpi-expense'),
38 kpiBalance: document.getElementById('kpi-balance'),
39 kpiBalanceSub: document.getElementById('kpi-balance-sub'),
40 kpiSavingsRate: document.getElementById('kpi-savings-rate'),
41
42
43 spendingCanvas: document.getElementById('spending-chart'),
44 donutCanvas: document.getElementById('donut-chart'),
45 chartGroupSelect: document.getElementById('chart-group-select'),
46
47
48 budgetListContainer: document.getElementById('budget-list-container'),
49 btnEditBudgets: document.getElementById('btn-edit-budgets'),
50
51
52 transactionsTbody: document.getElementById('transactions-tbody'),
53 txCountBadge: document.getElementById('tx-count-badge'),
54 searchInput: document.getElementById('search-input'),
55 categoryFilter: document.getElementById('category-filter'),
56 typeFilter: document.getElementById('type-filter'),
57 startDateFilter: document.getElementById('start-date-filter'),
58 endDateFilter: document.getElementById('end-date-filter'),
59 btnClearFilters: document.getElementById('btn-clear-filters'),
60
61
62 btnThemeToggle: document.getElementById('btn-theme-toggle'),
63 btnShortcuts: document.getElementById('btn-shortcuts'),
64 btnImportCSV: document.getElementById('btn-import-csv'),
65 btnExportCSV: document.getElementById('btn-export-csv'),
66 btnAddTx: document.getElementById('btn-add-tx'),
67 btnResetSample: document.getElementById('btn-reset-sample'),
68
69
70 modalTx: document.getElementById('modal-transaction'),
71 formTx: document.getElementById('form-transaction'),
72 txTitle: document.getElementById('modal-tx-title'),
73 txId: document.getElementById('tx-id'),
74 txType: document.getElementById('tx-type'),
75 txDate: document.getElementById('tx-date'),
76 txDesc: document.getElementById('tx-description'),
77 txAmount: document.getElementById('tx-amount'),
78 txCategory: document.getElementById('tx-category'),
79 txNotes: document.getElementById('tx-notes'),
80
81 modalImport: document.getElementById('modal-import'),
82 csvFileInput: document.getElementById('csv-file-input'),
83 csvRawText: document.getElementById('csv-raw-text'),
84 csvPreviewMsg: document.getElementById('csv-preview-msg'),
85 btnConfirmImport: document.getElementById('btn-confirm-import'),
86
87 modalBudgets: document.getElementById('modal-budgets'),
88 formBudgets: document.getElementById('form-budgets'),
89 budgetInputsContainer: document.getElementById('budget-inputs-container'),
90
91 modalShortcuts: document.getElementById('modal-shortcuts'),
92 toastContainer: document.getElementById('toast-container')
93 };
94 }
95
96
97
98 function init() {
99 cacheDOMElements();
100
101
102 const savedTheme = localStorage.getItem('pf_dashboard_theme') || 'dark';
103 setTheme(savedTheme);
104
105
106 state.transactions = service.loadTransactions();
107 state.budgets = service.loadBudgets();
108
109
110 populateCategoryDropdowns();
111
112
113 if (typeof FinanceCharts !== 'undefined') {
114 state.spendingChart = new FinanceCharts.SpendingChart(el.spendingCanvas, {
115 isDark: state.theme === 'dark'
116 });
117 state.donutChart = new FinanceCharts.CategoryDonutChart(el.donutCanvas, {
118 isDark: state.theme === 'dark'
119 });
120 }
121
122
123 attachEventListeners();
124
125
126 el.txDate.value = new Date().toISOString().split('T')[0];
127
128
129 renderAll();
130
131
132 window.addEventListener('resize', () => {
133 renderCharts();
134 });
135 }
136
137 function populateCategoryDropdowns() {
138
139 el.categoryFilter.innerHTML = '<option value="All">All Categories</option>';
140 categories.forEach(cat => {
141 const opt = document.createElement('option');
142 opt.value = cat;
143 opt.textContent = cat;
144 el.categoryFilter.appendChild(opt);
145 });
146
147
148 el.txCategory.innerHTML = '';
149 categories.forEach(cat => {
150 const opt = document.createElement('option');
151 opt.value = cat;
152 opt.textContent = cat;
153 el.txCategory.appendChild(opt);
154 });
155 }
156
157
158
159 function setTheme(theme) {
160 state.theme = theme;
161 el.html.setAttribute('data-theme', theme);
162 localStorage.setItem('pf_dashboard_theme', theme);
163 if (el.btnThemeToggle) {
164 el.btnThemeToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
165 }
166 const isDark = theme === 'dark';
167 if (state.spendingChart) state.spendingChart.setTheme(isDark);
168 if (state.donutChart) state.donutChart.setTheme(isDark);
169 }
170
171 function toggleTheme() {
172 setTheme(state.theme === 'dark' ? 'light' : 'dark');
173 showToast(`Switched to ${state.theme} theme`, 'info');
174 }
175
176
177
178 function renderAll() {
179 const filtered = service.filterTransactions(state.transactions, state.filters);
180
181 renderKPIs(filtered);
182 renderTransactionsTable(filtered);
183 renderBudgetBars();
184 renderCharts();
185 }
186
187
188
189 function renderKPIs(filteredTransactions) {
190 const summary = service.calculateSummary(filteredTransactions);
191
192 el.kpiIncome.textContent = service.formatCurrency(summary.totalIncome);
193 el.kpiExpense.textContent = service.formatCurrency(summary.totalExpense);
194
195 el.kpiBalance.textContent = service.formatCurrency(summary.netBalance);
196 if (summary.netBalance >= 0) {
197 el.kpiBalance.style.color = 'var(--income-green)';
198 el.kpiBalanceSub.textContent = 'Positive cashflow';
199 } else {
200 el.kpiBalance.style.color = 'var(--expense-rose)';
201 el.kpiBalanceSub.textContent = 'Deficit spending';
202 }
203
204 el.kpiSavingsRate.textContent = summary.savingsRate + '%';
205 if (summary.savingsRate >= 20) {
206 el.kpiSavingsRate.style.color = 'var(--income-green)';
207 } else if (summary.savingsRate >= 10) {
208 el.kpiSavingsRate.style.color = 'var(--warning-amber)';
209 } else {
210 el.kpiSavingsRate.style.color = 'var(--expense-rose)';
211 }
212 }
213
214
215
216 function renderTransactionsTable(transactions) {
217 el.txCountBadge.textContent = `${transactions.length} ${transactions.length === 1 ? 'entry' : 'entries'}`;
218 el.transactionsTbody.innerHTML = '';
219
220 if (transactions.length === 0) {
221 const tr = document.createElement('tr');
222 tr.innerHTML = `
223 <td colspan="7" style="text-align: center; padding: 32px; color: var(--text-muted);">
224 No transactions match your current filters.
225 </td>
226 `;
227 el.transactionsTbody.appendChild(tr);
228 return;
229 }
230
231 transactions.forEach(tx => {
232 const tr = document.createElement('tr');
233 const isIncome = tx.type === 'income';
234
235 tr.innerHTML = `
236 <td>${service.formatDate(tx.date)}</td>
237 <td style="font-weight: 600;">${escapeHTML(tx.description)}</td>
238 <td><span class="badge badge-category">${escapeHTML(tx.category)}</span></td>
239 <td><span class="badge ${isIncome ? 'badge-income' : 'badge-expense'}">${isIncome ? 'Income' : 'Expense'}</span></td>
240 <td class="${isIncome ? 'amount-income' : 'amount-expense'}">
241 ${isIncome ? '+' : '-'}${service.formatCurrency(tx.amount)}
242 </td>
243 <td style="color: var(--text-secondary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
244 ${escapeHTML(tx.notes || '-')}
245 </td>
246 <td style="text-align: right;">
247 <div class="action-buttons" style="justify-content: flex-end;">
248 <button class="action-btn edit-tx-btn" data-id="${tx.id}" title="Edit Transaction">✏️</button>
249 <button class="action-btn delete delete-tx-btn" data-id="${tx.id}" title="Delete Transaction">🗑️</button>
250 </div>
251 </td>
252 `;
253
254 el.transactionsTbody.appendChild(tr);
255 });
256 }
257
258
259
260 function renderBudgetBars() {
261 el.budgetListContainer.innerHTML = '';
262 const spendingMap = service.calculateCategorySpending(state.transactions);
263
264 Object.keys(state.budgets).forEach(cat => {
265 const limit = state.budgets[cat] || 0;
266 const spent = spendingMap[cat] || 0;
267 const percent = limit > 0 ? Math.min(100, Math.round((spent / limit) * 100)) : 0;
268 const isExceeded = spent > limit;
269
270 let fillColor = 'var(--income-green)';
271 let statusTag = 'On Track';
272 let tagBg = 'var(--income-green-bg)';
273 let tagColor = 'var(--income-green)';
274
275 if (isExceeded) {
276 fillColor = 'var(--expense-rose)';
277 statusTag = 'Exceeded';
278 tagBg = 'var(--expense-rose-bg)';
279 tagColor = 'var(--expense-rose)';
280 } else if (percent >= 80) {
281 fillColor = 'var(--warning-amber)';
282 statusTag = 'Warning';
283 tagBg = 'var(--warning-amber-bg)';
284 tagColor = 'var(--warning-amber)';
285 }
286
287 const item = document.createElement('div');
288 item.className = 'budget-item';
289 item.innerHTML = `
290 <div class="budget-item-header">
291 <div class="budget-item-cat">
292 <span>${escapeHTML(cat)}</span>
293 <span class="budget-status-tag" style="background: ${tagBg}; color: ${tagColor};">${statusTag}</span>
294 </div>
295 <div class="budget-item-values">
296 $${spent.toLocaleString()} / <span style="color: var(--text-muted);">$${limit.toLocaleString()}</span>
297 </div>
298 </div>
299 <div class="budget-progress-track">
300 <div class="budget-progress-fill" style="width: ${percent}%; background-color: ${fillColor};"></div>
301 </div>
302 `;
303
304 el.budgetListContainer.appendChild(item);
305 });
306 }
307
308
309
310 function renderCharts() {
311 const filtered = service.filterTransactions(state.transactions, state.filters);
312
313
314 if (state.spendingChart) {
315 const timeData = service.calculateSpendingOverTime(filtered, state.chartGroupMode);
316 state.spendingChart.setData(timeData);
317 }
318
319
320 if (state.donutChart) {
321 const catSpending = service.calculateCategorySpending(filtered);
322 state.donutChart.setData(catSpending);
323 }
324 }
325
326
327
328 function openModal(modalEl) {
329 if (modalEl) modalEl.classList.add('active');
330 }
331
332 function closeModal(modalEl) {
333 if (modalEl) modalEl.classList.remove('active');
334 }
335
336 function closeAllModals() {
337 document.querySelectorAll('.modal-backdrop').forEach(m => m.classList.remove('active'));
338 }
339
340
341
342 function openNewTxModal() {
343 state.editingTxId = null;
344 el.txTitle.textContent = 'New Transaction';
345 el.txId.value = '';
346 el.txType.value = 'expense';
347 el.txDate.value = new Date().toISOString().split('T')[0];
348 el.txDesc.value = '';
349 el.txAmount.value = '';
350 el.txCategory.value = categories[0] || 'Other';
351 el.txNotes.value = '';
352 openModal(el.modalTx);
353 setTimeout(() => el.txDesc.focus(), 100);
354 }
355
356 function openEditTxModal(txId) {
357 const tx = state.transactions.find(t => t.id === txId);
358 if (!tx) return;
359
360 state.editingTxId = txId;
361 el.txTitle.textContent = 'Edit Transaction';
362 el.txId.value = tx.id;
363 el.txType.value = tx.type;
364 el.txDate.value = tx.date;
365 el.txDesc.value = tx.description;
366 el.txAmount.value = tx.amount;
367 el.txCategory.value = tx.category;
368 el.txNotes.value = tx.notes || '';
369
370 openModal(el.modalTx);
371 }
372
373 function handleTxFormSubmit(e) {
374 e.preventDefault();
375
376 const txData = {
377 id: el.txId.value || undefined,
378 type: el.txType.value,
379 date: el.txDate.value,
380 description: el.txDesc.value,
381 amount: parseFloat(el.txAmount.value),
382 category: el.txCategory.value,
383 notes: el.txNotes.value
384 };
385
386 try {
387 if (state.editingTxId) {
388 state.transactions = service.updateTransaction(state.transactions, state.editingTxId, txData);
389 showToast('Transaction updated successfully', 'success');
390 } else {
391 state.transactions = service.addTransaction(state.transactions, txData);
392 showToast('New transaction added', 'success');
393 }
394 closeModal(el.modalTx);
395 renderAll();
396 } catch (err) {
397 showToast(err.message || 'Error saving transaction', 'error');
398 }
399 }
400
401 function handleDeleteTx(txId) {
402 if (confirm('Are you sure you want to delete this transaction?')) {
403 state.transactions = service.deleteTransaction(state.transactions, txId);
404 showToast('Transaction deleted', 'info');
405 renderAll();
406 }
407 }
408
409
410
411 function openBudgetsModal() {
412 el.budgetInputsContainer.innerHTML = '';
413 categories.forEach(cat => {
414 if (cat === 'Income') return;
415 const limit = state.budgets[cat] !== undefined ? state.budgets[cat] : 500;
416
417 const div = document.createElement('div');
418 div.className = 'form-group';
419 div.style.marginBottom = '8px';
420 div.innerHTML = `
421 <label class="form-label" style="font-size: 0.85rem;">${escapeHTML(cat)} Budget ($)</label>
422 <input type="number" class="form-input budget-input-field" data-cat="${escapeHTML(cat)}" value="${limit}" min="0" step="50" />
423 `;
424 el.budgetInputsContainer.appendChild(div);
425 });
426 openModal(el.modalBudgets);
427 }
428
429 function handleBudgetsFormSubmit(e) {
430 e.preventDefault();
431 const newBudgets = { ...state.budgets };
432 const inputs = el.budgetInputsContainer.querySelectorAll('.budget-input-field');
433
434 inputs.forEach(input => {
435 const cat = input.getAttribute('data-cat');
436 const val = parseFloat(input.value);
437 if (cat && !isNaN(val) && val >= 0) {
438 newBudgets[cat] = val;
439 }
440 });
441
442 state.budgets = newBudgets;
443 service.saveBudgets(state.budgets);
444 showToast('Budget limits updated', 'success');
445 closeModal(el.modalBudgets);
446 renderBudgetBars();
447 }
448
449
450
451 function handleExportCSV() {
452 const csvContent = service.exportToCSV(state.transactions);
453 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
454 const url = URL.createObjectURL(blob);
455
456 const a = document.createElement('a');
457 a.href = url;
458 a.download = `WealthPulse_Transactions_${new Date().toISOString().split('T')[0]}.csv`;
459 document.body.appendChild(a);
460 a.click();
461 document.body.removeChild(a);
462 URL.revokeObjectURL(url);
463
464 showToast('Exported CSV file', 'success');
465 }
466
467 function openImportCSVModal() {
468 el.csvFileInput.value = '';
469 el.csvRawText.value = '';
470 el.csvPreviewMsg.textContent = '';
471 openModal(el.modalImport);
472 }
473
474 function handleCSVFileInput(e) {
475 const file = e.target.files[0];
476 if (!file) return;
477 const reader = new FileReader();
478 reader.onload = function (evt) {
479 el.csvRawText.value = evt.target.result;
480 };
481 reader.readAsText(file);
482 }
483
484 function handleConfirmImport() {
485 const text = el.csvRawText.value;
486 if (!text || text.trim() === '') {
487 showToast('Please select a CSV file or paste CSV text', 'error');
488 return;
489 }
490
491 try {
492 const parsed = service.parseCSV(text);
493 if (parsed.transactions.length === 0) {
494 showToast('No valid transactions found in CSV', 'error');
495 return;
496 }
497
498
499 let addedCount = 0;
500 parsed.transactions.forEach(tx => {
501 state.transactions = service.addTransaction(state.transactions, tx);
502 addedCount++;
503 });
504
505 showToast(`Successfully imported ${addedCount} transactions!`, 'success');
506 closeModal(el.modalImport);
507 renderAll();
508 } catch (err) {
509 showToast(err.message || 'Error parsing CSV', 'error');
510 }
511 }
512
513
514
515 function attachEventListeners() {
516
517 el.btnThemeToggle.addEventListener('click', toggleTheme);
518 el.btnShortcuts.addEventListener('click', () => openModal(el.modalShortcuts));
519 el.btnAddTx.addEventListener('click', openNewTxModal);
520 el.btnExportCSV.addEventListener('click', handleExportCSV);
521 el.btnImportCSV.addEventListener('click', openImportCSVModal);
522 el.btnEditBudgets.addEventListener('click', openBudgetsModal);
523
524 el.btnResetSample.addEventListener('click', () => {
525 if (confirm('Reset dashboard to default sample transactions and budgets?')) {
526 const res = service.resetToSampleData();
527 state.transactions = res.transactions;
528 state.budgets = res.budgets;
529 showToast('Reset data to default sample', 'info');
530 renderAll();
531 }
532 });
533
534
535 el.formTx.addEventListener('submit', handleTxFormSubmit);
536 el.formBudgets.addEventListener('submit', handleBudgetsFormSubmit);
537 el.btnConfirmImport.addEventListener('click', handleConfirmImport);
538 el.csvFileInput.addEventListener('change', handleCSVFileInput);
539
540
541 document.querySelectorAll('[data-close-modal]').forEach(btn => {
542 btn.addEventListener('click', () => {
543 const modalId = btn.getAttribute('data-close-modal');
544 closeModal(document.getElementById(modalId));
545 });
546 });
547
548
549 document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
550 backdrop.addEventListener('click', e => {
551 if (e.target === backdrop) closeModal(backdrop);
552 });
553 });
554
555
556 el.searchInput.addEventListener('input', e => {
557 state.filters.search = e.target.value;
558 renderAll();
559 });
560
561 el.categoryFilter.addEventListener('change', e => {
562 state.filters.category = e.target.value;
563 renderAll();
564 });
565
566 el.typeFilter.addEventListener('change', e => {
567 state.filters.type = e.target.value;
568 renderAll();
569 });
570
571 el.startDateFilter.addEventListener('change', e => {
572 state.filters.startDate = e.target.value;
573 renderAll();
574 });
575
576 el.endDateFilter.addEventListener('change', e => {
577 state.filters.endDate = e.target.value;
578 renderAll();
579 });
580
581 el.btnClearFilters.addEventListener('click', () => {
582 state.filters = { search: '', category: 'All', type: 'All', startDate: '', endDate: '' };
583 el.searchInput.value = '';
584 el.categoryFilter.value = 'All';
585 el.typeFilter.value = 'All';
586 el.startDateFilter.value = '';
587 el.endDateFilter.value = '';
588 renderAll();
589 showToast('Filters cleared', 'info');
590 });
591
592 el.chartGroupSelect.addEventListener('change', e => {
593 state.chartGroupMode = e.target.value;
594 renderCharts();
595 });
596
597
598 el.transactionsTbody.addEventListener('click', e => {
599 const editBtn = e.target.closest('.edit-tx-btn');
600 if (editBtn) {
601 const id = editBtn.getAttribute('data-id');
602 openEditTxModal(id);
603 return;
604 }
605
606 const deleteBtn = e.target.closest('.delete-tx-btn');
607 if (deleteBtn) {
608 const id = deleteBtn.getAttribute('data-id');
609 handleDeleteTx(id);
610 return;
611 }
612 });
613
614
615 window.addEventListener('keydown', handleGlobalKeydown);
616 }
617
618
619
620 function handleGlobalKeydown(e) {
621
622 const active = document.activeElement;
623 const isInput = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT');
624
625
626 if (e.key === 'Escape') {
627 closeAllModals();
628 return;
629 }
630
631 if (isInput) return;
632
633 if (e.key === 'n' || e.key === 'N') {
634 e.preventDefault();
635 openNewTxModal();
636 } else if (e.key === '/') {
637 e.preventDefault();
638 el.searchInput.focus();
639 } else if (e.key === 'd' || e.key === 'D') {
640 e.preventDefault();
641 toggleTheme();
642 } else if (e.key === 'e' || e.key === 'E') {
643 e.preventDefault();
644 handleExportCSV();
645 } else if (e.key === 'i' || e.key === 'I') {
646 e.preventDefault();
647 openImportCSVModal();
648 } else if (e.key === '?') {
649 e.preventDefault();
650 openModal(el.modalShortcuts);
651 }
652 }
653
654
655
656 function showToast(message, type = 'info') {
657 if (!el.toastContainer) return;
658 const toast = document.createElement('div');
659 toast.className = 'toast';
660
661 let icon = 'ℹ️';
662 if (type === 'success') icon = '✅';
663 if (type === 'error') icon = '⚠️';
664
665 toast.innerHTML = `<span>${icon}</span> <span>${escapeHTML(message)}</span>`;
666 el.toastContainer.appendChild(toast);
667
668 setTimeout(() => {
669 toast.style.opacity = '0';
670 toast.style.transform = 'translateY(10px)';
671 toast.style.transition = 'all 0.3s ease';
672 setTimeout(() => toast.remove(), 300);
673 }, 3000);
674 }
675
676
677
678 function escapeHTML(str) {
679 if (typeof str !== 'string') return str;
680 return str.replace(/[&<>'"]/g, tag => ({
681 '&': '&',
682 '<': '<',
683 '>': '>',
684 "'": ''',
685 '"': '"'
686 }[tag] || tag));
687 }
688
689
690 if (document.readyState === 'loading') {
691 document.addEventListener('DOMContentLoaded', init);
692 } else {
693 init();
694 }
695})();
696
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.