|
|
|
|
@ -2,312 +2,231 @@
|
|
|
|
|
// Constants
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const serverUrl = 'http://localhost:5000/api'; // reserved for future server swap
|
|
|
|
|
const serverUrl = 'http://localhost:5000/api';
|
|
|
|
|
const storageKey = 'savedAccount';
|
|
|
|
|
const accountsKey = 'accounts';
|
|
|
|
|
const schemaKey = 'schemaVersion';
|
|
|
|
|
const schemaVersion = 1;
|
|
|
|
|
const accountsKey = 'accounts'; // New key for all accounts
|
|
|
|
|
const themeKey = 'appTheme'; // <--- NEW: Constant for theme storage
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Intl helpers
|
|
|
|
|
// Theme Toggle Logic <--- NEW SECTION
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const userLocale = navigator.language || 'en-IN';
|
|
|
|
|
|
|
|
|
|
function isIsoCurrency(code) {
|
|
|
|
|
if (!code) return false;
|
|
|
|
|
const c = String(code).trim().toUpperCase();
|
|
|
|
|
if (!/^[A-Z]{3}$/.test(c)) return false;
|
|
|
|
|
// Verify against supported values when available
|
|
|
|
|
try {
|
|
|
|
|
if (typeof Intl.supportedValuesOf === 'function') {
|
|
|
|
|
return Intl.supportedValuesOf('currency').includes(c);
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
return true; // fallback accept 3-letter code
|
|
|
|
|
window.toggleTheme = function() {
|
|
|
|
|
const html = document.documentElement;
|
|
|
|
|
const isDark = html.classList.toggle('dark-mode');
|
|
|
|
|
|
|
|
|
|
// Toggle visibility of sun/moon icons based on the active class
|
|
|
|
|
const sun = document.getElementById('sun-icon');
|
|
|
|
|
const moon = document.getElementById('moon-icon');
|
|
|
|
|
if (sun && moon) {
|
|
|
|
|
// If dark mode is active (isDark is true), hide the moon and show the sun
|
|
|
|
|
moon.classList.toggle('hidden', isDark);
|
|
|
|
|
sun.classList.toggle('hidden', !isDark);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save preference to local storage
|
|
|
|
|
localStorage.setItem(themeKey, isDark ? 'dark' : 'light');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toCurrency(amount, currency) {
|
|
|
|
|
const n = Number(amount);
|
|
|
|
|
if (!Number.isFinite(n)) return String(amount);
|
|
|
|
|
const c = String(currency || '').trim();
|
|
|
|
|
if (isIsoCurrency(c)) {
|
|
|
|
|
// Accounting style shows negatives in parentheses if supported
|
|
|
|
|
const fmt = new Intl.NumberFormat(userLocale, {
|
|
|
|
|
style: 'currency',
|
|
|
|
|
currency: c.toUpperCase(),
|
|
|
|
|
currencySign: 'accounting',
|
|
|
|
|
maximumFractionDigits: 2
|
|
|
|
|
});
|
|
|
|
|
return fmt.format(n);
|
|
|
|
|
function applyThemeOnLoad() {
|
|
|
|
|
const html = document.documentElement;
|
|
|
|
|
const savedTheme = localStorage.getItem(themeKey);
|
|
|
|
|
|
|
|
|
|
// Default to system preference if no saved theme is found
|
|
|
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
|
|
|
|
|
|
|
|
let activateDark;
|
|
|
|
|
if (savedTheme) {
|
|
|
|
|
activateDark = savedTheme === 'dark';
|
|
|
|
|
} else {
|
|
|
|
|
activateDark = prefersDark;
|
|
|
|
|
}
|
|
|
|
|
// Fallback: symbol + localized number
|
|
|
|
|
const num = new Intl.NumberFormat(userLocale, {
|
|
|
|
|
maximumFractionDigits: 2
|
|
|
|
|
}).format(n);
|
|
|
|
|
return c ? `${c} ${num}` : num;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toDate(dateStr) {
|
|
|
|
|
// Expect yyyy-mm-dd; fallback to today if invalid
|
|
|
|
|
const d = dateStr ? new Date(dateStr) : new Date();
|
|
|
|
|
if (Number.isNaN(d.getTime())) return new Date();
|
|
|
|
|
return d;
|
|
|
|
|
}
|
|
|
|
|
if (activateDark) {
|
|
|
|
|
html.classList.add('dark-mode');
|
|
|
|
|
} else {
|
|
|
|
|
html.classList.remove('dark-mode');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(dateStr) {
|
|
|
|
|
const d = toDate(dateStr);
|
|
|
|
|
const fmt = new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' });
|
|
|
|
|
return fmt.format(d);
|
|
|
|
|
// Ensure correct icon is visible on load (needs to wait for DOM elements)
|
|
|
|
|
// This logic runs again in updateRoute or dashboard refresh to ensure icons are set.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Attach the function to run after the DOM content is loaded
|
|
|
|
|
document.addEventListener('DOMContentLoaded', applyThemeOnLoad);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Storage and state
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function safeParse(json, fallback) {
|
|
|
|
|
try { return JSON.parse(json); } catch { return fallback; }
|
|
|
|
|
const routes = {
|
|
|
|
|
'/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh },
|
|
|
|
|
'/login': { title: 'Login', templateId: 'login' }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function navigate(path) {
|
|
|
|
|
window.history.pushState({}, path, window.location.origin + path);
|
|
|
|
|
updateRoute();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateRoute() {
|
|
|
|
|
const path = window.location.pathname;
|
|
|
|
|
const route = routes[path];
|
|
|
|
|
|
|
|
|
|
if (!route) {
|
|
|
|
|
return navigate('/dashboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const template = document.getElementById(route.templateId);
|
|
|
|
|
const view = template.content.cloneNode(true);
|
|
|
|
|
const app = document.getElementById('app');
|
|
|
|
|
app.innerHTML = '';
|
|
|
|
|
app.appendChild(view);
|
|
|
|
|
|
|
|
|
|
if (typeof route.init === 'function') {
|
|
|
|
|
route.init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-run icon setup after template is rendered
|
|
|
|
|
applyThemeOnLoad(); // <--- UPDATED: Call applyThemeOnLoad here too, to set icon state
|
|
|
|
|
|
|
|
|
|
document.title = route.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// API interactions (replaced with localStorage logic)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function getAccounts() {
|
|
|
|
|
return safeParse(localStorage.getItem(accountsKey), []);
|
|
|
|
|
return JSON.parse(localStorage.getItem(accountsKey) || '[]');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveAccounts(accounts) {
|
|
|
|
|
localStorage.setItem(accountsKey, JSON.stringify(accounts));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function migrateSchema() {
|
|
|
|
|
const v = Number(localStorage.getItem(schemaKey) || 0);
|
|
|
|
|
if (v >= schemaVersion) return;
|
|
|
|
|
let accounts = getAccounts();
|
|
|
|
|
// Example migration scaffolding:
|
|
|
|
|
// if (v < 1) { /* future migrations */ }
|
|
|
|
|
saveAccounts(accounts);
|
|
|
|
|
localStorage.setItem(schemaKey, String(schemaVersion));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findAccount(user) {
|
|
|
|
|
const accounts = getAccounts();
|
|
|
|
|
return accounts.find(acc => acc.user === user) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getAccount(user) {
|
|
|
|
|
// Simulate async
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const acc = findAccount(user);
|
|
|
|
|
resolve(acc || { error: 'Account not found' });
|
|
|
|
|
}, 60);
|
|
|
|
|
if (!acc) resolve({ error: 'Account not found' });
|
|
|
|
|
else resolve(acc);
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function uuid() {
|
|
|
|
|
// Works on HTTPS and localhost; fallback otherwise
|
|
|
|
|
if (globalThis.crypto && typeof crypto.randomUUID === 'function') {
|
|
|
|
|
return crypto.randomUUID();
|
|
|
|
|
}
|
|
|
|
|
return 'tx-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createAccount(accountJson) {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const data = safeParse(accountJson, null);
|
|
|
|
|
if (!data) return resolve({ error: 'Malformed account data' });
|
|
|
|
|
const user = String(data.user || '').trim();
|
|
|
|
|
if (!user) return resolve({ error: 'Username required' });
|
|
|
|
|
if (findAccount(user)) return resolve({ error: 'User already exists' });
|
|
|
|
|
|
|
|
|
|
const currency = String(data.currency || 'INR').trim();
|
|
|
|
|
let data;
|
|
|
|
|
try {
|
|
|
|
|
data = JSON.parse(accountJson);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return resolve({ error: 'Malformed account data' });
|
|
|
|
|
}
|
|
|
|
|
if (!data.user) return resolve({ error: 'Username required' });
|
|
|
|
|
if (findAccount(data.user)) return resolve({ error: 'User already exists' });
|
|
|
|
|
// Set up initial account structure
|
|
|
|
|
const newAcc = {
|
|
|
|
|
user,
|
|
|
|
|
description: String(data.description || ''),
|
|
|
|
|
balance: Number(data.balance || 0) || 0,
|
|
|
|
|
currency: isIsoCurrency(currency) ? currency.toUpperCase() : currency,
|
|
|
|
|
user: data.user,
|
|
|
|
|
description: data.description || '',
|
|
|
|
|
balance: 0,
|
|
|
|
|
currency: data.currency || 'USD',
|
|
|
|
|
transactions: []
|
|
|
|
|
};
|
|
|
|
|
const accounts = getAccounts();
|
|
|
|
|
accounts.push(newAcc);
|
|
|
|
|
saveAccounts(accounts);
|
|
|
|
|
resolve(newAcc);
|
|
|
|
|
}, 80);
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createTransaction(user, transactionJson) {
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const tx = safeParse(transactionJson, null);
|
|
|
|
|
if (!tx) return resolve({ error: 'Malformed transaction data' });
|
|
|
|
|
|
|
|
|
|
const amount = Number(tx.amount);
|
|
|
|
|
const object = String(tx.object || '').trim();
|
|
|
|
|
const dateStr = tx.date || new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(amount)) return resolve({ error: 'Amount must be a valid number' });
|
|
|
|
|
if (!object) return resolve({ error: 'Object is required' });
|
|
|
|
|
|
|
|
|
|
const accounts = getAccounts();
|
|
|
|
|
const idx = accounts.findIndex(acc => acc.user === user);
|
|
|
|
|
if (idx === -1) return resolve({ error: 'Account not found' });
|
|
|
|
|
|
|
|
|
|
const newTx = {
|
|
|
|
|
id: uuid(),
|
|
|
|
|
date: dateStr,
|
|
|
|
|
object,
|
|
|
|
|
amount
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update balance and push transaction
|
|
|
|
|
accounts[idx].balance = (Number(accounts[idx].balance) || 0) + amount;
|
|
|
|
|
accounts[idx].transactions = (accounts[idx].transactions || []);
|
|
|
|
|
accounts[idx].transactions.push(newTx);
|
|
|
|
|
|
|
|
|
|
const tx = JSON.parse(transactionJson);
|
|
|
|
|
tx.amount = parseFloat(tx.amount);
|
|
|
|
|
tx.date = tx.date || new Date().toISOString().slice(0, 10);
|
|
|
|
|
accounts[idx].balance += tx.amount;
|
|
|
|
|
accounts[idx].transactions.push(tx);
|
|
|
|
|
saveAccounts(accounts);
|
|
|
|
|
resolve(newTx);
|
|
|
|
|
}, 80);
|
|
|
|
|
resolve(tx);
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep a frozen state object to avoid accidental mutations
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Global state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let state = Object.freeze({
|
|
|
|
|
account: null
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function updateState(property, newData) {
|
|
|
|
|
state = Object.freeze({ ...state, [property]: newData });
|
|
|
|
|
// Persist active account only
|
|
|
|
|
state = Object.freeze({
|
|
|
|
|
...state,
|
|
|
|
|
[property]: newData
|
|
|
|
|
});
|
|
|
|
|
localStorage.setItem(storageKey, JSON.stringify(state.account));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cross-tab sync: refresh when accounts or active account change in another tab
|
|
|
|
|
window.addEventListener('storage', (e) => {
|
|
|
|
|
if (e.key === accountsKey || e.key === storageKey) {
|
|
|
|
|
if (state.account?.user) {
|
|
|
|
|
refresh().catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// DOM helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function qs(id) {
|
|
|
|
|
return document.getElementById(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateElement(id, textOrNode) {
|
|
|
|
|
const el = qs(id);
|
|
|
|
|
if (!el) return;
|
|
|
|
|
while (el.firstChild) el.removeChild(el.firstChild);
|
|
|
|
|
el.append(textOrNode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve multiple possible IDs used by older markup
|
|
|
|
|
function setTextByAnyId(ids, text) {
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const el = qs(id);
|
|
|
|
|
if (el) { el.textContent = text; return true; }
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const routes = {
|
|
|
|
|
'/dashboard': { title: 'My Account', templateId: 'dashboard', init: refresh },
|
|
|
|
|
'/login': { title: 'Login', templateId: 'login', init: attachAuthHandlers }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function navigate(path) {
|
|
|
|
|
// Store path in history state and URL
|
|
|
|
|
history.pushState({ path }, '', path);
|
|
|
|
|
updateRoute();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateRoute() {
|
|
|
|
|
const path = history.state?.path || window.location.pathname;
|
|
|
|
|
const route = routes[path] || routes['/dashboard'];
|
|
|
|
|
|
|
|
|
|
const template = document.getElementById(route.templateId);
|
|
|
|
|
const view = template.content.cloneNode(true);
|
|
|
|
|
const app = document.getElementById('app');
|
|
|
|
|
app.innerHTML = '';
|
|
|
|
|
app.appendChild(view);
|
|
|
|
|
|
|
|
|
|
// Attach handlers after DOM is rendered
|
|
|
|
|
attachGlobalHandlers();
|
|
|
|
|
|
|
|
|
|
if (typeof route.init === 'function') {
|
|
|
|
|
Promise.resolve(route.init()).catch(err => console.error(err));
|
|
|
|
|
}
|
|
|
|
|
document.title = route.title;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Browser back/forward
|
|
|
|
|
window.addEventListener('popstate', () => updateRoute());
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Auth
|
|
|
|
|
// Login/register
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function login() {
|
|
|
|
|
const form = qs('loginForm');
|
|
|
|
|
if (!form) return;
|
|
|
|
|
if (!form.checkValidity()) { form.reportValidity(); return; }
|
|
|
|
|
|
|
|
|
|
const user = String(form.user.value || '').trim();
|
|
|
|
|
const loginForm = document.getElementById('loginForm')
|
|
|
|
|
const user = loginForm.user.value;
|
|
|
|
|
const data = await getAccount(user);
|
|
|
|
|
if (data.error) return updateElement('loginError', data.error);
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
return updateElement('loginError', data.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateState('account', data);
|
|
|
|
|
navigate('/dashboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function register() {
|
|
|
|
|
const form = qs('registerForm');
|
|
|
|
|
if (!form) return;
|
|
|
|
|
if (!form.checkValidity()) { form.reportValidity(); return; }
|
|
|
|
|
|
|
|
|
|
const data = Object.fromEntries(new FormData(form));
|
|
|
|
|
data.user = String(data.user || '').trim();
|
|
|
|
|
data.currency = String(data.currency || '').trim();
|
|
|
|
|
data.description = String(data.description || '').trim();
|
|
|
|
|
data.balance = Number(data.balance || 0) || 0;
|
|
|
|
|
|
|
|
|
|
const registerForm = document.getElementById('registerForm');
|
|
|
|
|
const formData = new FormData(registerForm);
|
|
|
|
|
const data = Object.fromEntries(formData);
|
|
|
|
|
const jsonData = JSON.stringify(data);
|
|
|
|
|
const result = await createAccount(jsonData);
|
|
|
|
|
|
|
|
|
|
if (result.error) return updateElement('registerError', result.error);
|
|
|
|
|
if (result.error) {
|
|
|
|
|
return updateElement('registerError', result.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateState('account', result);
|
|
|
|
|
navigate('/dashboard');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function attachAuthHandlers() {
|
|
|
|
|
const loginForm = qs('loginForm');
|
|
|
|
|
if (loginForm) {
|
|
|
|
|
loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); });
|
|
|
|
|
}
|
|
|
|
|
const registerForm = qs('registerForm');
|
|
|
|
|
if (registerForm) {
|
|
|
|
|
registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Dashboard
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function updateAccountData() {
|
|
|
|
|
const account = state.account;
|
|
|
|
|
if (!account) return logout();
|
|
|
|
|
if (!account) {
|
|
|
|
|
return logout();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await getAccount(account.user);
|
|
|
|
|
if (data.error) return logout();
|
|
|
|
|
if (data.error) {
|
|
|
|
|
return logout();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateState('account', data);
|
|
|
|
|
}
|
|
|
|
|
@ -319,133 +238,89 @@ async function refresh() {
|
|
|
|
|
|
|
|
|
|
function updateDashboard() {
|
|
|
|
|
const account = state.account;
|
|
|
|
|
if (!account) return logout();
|
|
|
|
|
|
|
|
|
|
// Description (support both #description and #transactions-description subtitles)
|
|
|
|
|
setTextByAnyId(['description', 'transactions-description'], account.description || 'Transactions');
|
|
|
|
|
|
|
|
|
|
// Balance and currency
|
|
|
|
|
const balanceText = toCurrency(account.balance, account.currency);
|
|
|
|
|
setTextByAnyId(['balance', 'balance-value'], balanceText);
|
|
|
|
|
if (!account) {
|
|
|
|
|
return logout();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Some markups use a separate currency span; keep it empty when using formatted output
|
|
|
|
|
setTextByAnyId(['balance-currency', 'currency'], '');
|
|
|
|
|
updateElement('description', account.description);
|
|
|
|
|
updateElement('balance', account.balance.toFixed(2));
|
|
|
|
|
updateElement('currency', account.currency);
|
|
|
|
|
|
|
|
|
|
// Transactions (sorted by date desc, then by insertion order)
|
|
|
|
|
const tbody = qs('transactions');
|
|
|
|
|
if (!tbody) return;
|
|
|
|
|
// Update transactions
|
|
|
|
|
const transactionsRows = document.createDocumentFragment();
|
|
|
|
|
for (const transaction of account.transactions) {
|
|
|
|
|
const transactionRow = createTransactionRow(transaction);
|
|
|
|
|
transactionsRows.appendChild(transactionRow);
|
|
|
|
|
}
|
|
|
|
|
updateElement('transactions', transactionsRows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const frag = document.createDocumentFragment();
|
|
|
|
|
function createTransactionRow(transaction) {
|
|
|
|
|
const template = document.getElementById('transaction');
|
|
|
|
|
|
|
|
|
|
const sorted = [...(account.transactions || [])]
|
|
|
|
|
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
|
|
|
|
|
|
|
|
|
for (const tx of sorted) {
|
|
|
|
|
const row = template.content.cloneNode(true);
|
|
|
|
|
const tr = row.querySelector('tr');
|
|
|
|
|
const tds = tr.children;
|
|
|
|
|
tds[0].textContent = formatDate(tx.date);
|
|
|
|
|
tds[1].textContent = tx.object;
|
|
|
|
|
tds[2].textContent = toCurrency(tx.amount, account.currency);
|
|
|
|
|
if (tx.amount < 0) tr.classList.add('debit');
|
|
|
|
|
if (tx.amount > 0) tr.classList.add('credit');
|
|
|
|
|
frag.appendChild(row);
|
|
|
|
|
const transactionRow = template.content.cloneNode(true);
|
|
|
|
|
const tr = transactionRow.querySelector('tr');
|
|
|
|
|
|
|
|
|
|
// START: FIX - FORMAT DATE
|
|
|
|
|
// The date is typically stored as 'YYYY-MM-DD'. We convert it to 'MM/DD/YYYY' for display.
|
|
|
|
|
const dateParts = transaction.date.split('-');
|
|
|
|
|
const displayDate = dateParts[1] + '/' + dateParts[2] + '/' + dateParts[0];
|
|
|
|
|
// END: FIX
|
|
|
|
|
|
|
|
|
|
tr.children[0].textContent = displayDate; // Use displayDate
|
|
|
|
|
tr.children[1].textContent = transaction.object;
|
|
|
|
|
tr.children[2].textContent = transaction.amount.toFixed(2);
|
|
|
|
|
|
|
|
|
|
// Optionally highlight positive/negative transactions
|
|
|
|
|
if (transaction.amount < 0) {
|
|
|
|
|
tr.classList.add('negative');
|
|
|
|
|
} else if (transaction.amount > 0) {
|
|
|
|
|
tr.classList.add('positive');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
|
tbody.appendChild(frag);
|
|
|
|
|
return transactionRow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addTransaction() {
|
|
|
|
|
const dialog = qs('transactionDialog');
|
|
|
|
|
if (!dialog) return;
|
|
|
|
|
const dialog = document.getElementById('transactionDialog');
|
|
|
|
|
dialog.classList.add('show');
|
|
|
|
|
|
|
|
|
|
// Reset form and set today
|
|
|
|
|
const form = qs('transactionForm');
|
|
|
|
|
if (form) {
|
|
|
|
|
form.reset();
|
|
|
|
|
form.date.valueAsDate = new Date();
|
|
|
|
|
// Move focus to first field
|
|
|
|
|
form.date.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close handlers
|
|
|
|
|
const backdrop = dialog.querySelector('[data-dismiss]') || dialog;
|
|
|
|
|
backdrop.addEventListener('click', onDialogDismissClick);
|
|
|
|
|
dialog.addEventListener('keydown', onDialogKeydown);
|
|
|
|
|
}
|
|
|
|
|
// Reset form
|
|
|
|
|
const transactionForm = document.getElementById('transactionForm');
|
|
|
|
|
transactionForm.reset();
|
|
|
|
|
|
|
|
|
|
function onDialogDismissClick(e) {
|
|
|
|
|
if (e.target?.hasAttribute?.('data-dismiss')) {
|
|
|
|
|
cancelTransaction();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onDialogKeydown(e) {
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
cancelTransaction();
|
|
|
|
|
}
|
|
|
|
|
// Set date to today
|
|
|
|
|
transactionForm.date.valueAsDate = new Date();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmTransaction() {
|
|
|
|
|
const form = qs('transactionForm');
|
|
|
|
|
if (!form) return;
|
|
|
|
|
|
|
|
|
|
// Inline validation
|
|
|
|
|
const amountVal = Number(form.amount.value);
|
|
|
|
|
const objectVal = String(form.object.value || '').trim();
|
|
|
|
|
if (!Number.isFinite(amountVal)) {
|
|
|
|
|
setFormError('transactionError', 'Amount must be a valid number');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!objectVal) {
|
|
|
|
|
setFormError('transactionError', 'Object is required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const dialog = document.getElementById('transactionDialog');
|
|
|
|
|
dialog.classList.remove('show');
|
|
|
|
|
|
|
|
|
|
clearFormError('transactionError');
|
|
|
|
|
const transactionForm = document.getElementById('transactionForm');
|
|
|
|
|
|
|
|
|
|
const jsonData = JSON.stringify(Object.fromEntries(new FormData(form)));
|
|
|
|
|
const formData = new FormData(transactionForm);
|
|
|
|
|
const jsonData = JSON.stringify(Object.fromEntries(formData));
|
|
|
|
|
const data = await createTransaction(state.account.user, jsonData);
|
|
|
|
|
|
|
|
|
|
if (data.error) {
|
|
|
|
|
setFormError('transactionError', data.error);
|
|
|
|
|
return;
|
|
|
|
|
return updateElement('transactionError', data.error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update local state
|
|
|
|
|
// Update local state with new transaction
|
|
|
|
|
const newAccount = {
|
|
|
|
|
...state.account,
|
|
|
|
|
balance: (Number(state.account.balance) || 0) + data.amount,
|
|
|
|
|
transactions: [...(state.account.transactions || []), data]
|
|
|
|
|
};
|
|
|
|
|
balance: state.account.balance + data.amount,
|
|
|
|
|
transactions: [...state.account.transactions, data]
|
|
|
|
|
}
|
|
|
|
|
updateState('account', newAccount);
|
|
|
|
|
|
|
|
|
|
// Close dialog and update view
|
|
|
|
|
cancelTransaction();
|
|
|
|
|
// Update display
|
|
|
|
|
updateDashboard();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setFormError(id, message) {
|
|
|
|
|
updateElement(id, message);
|
|
|
|
|
const el = qs(id);
|
|
|
|
|
if (el) el.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearFormError(id) {
|
|
|
|
|
const el = qs(id);
|
|
|
|
|
if (el) el.textContent = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelTransaction() {
|
|
|
|
|
const dialog = qs('transactionDialog');
|
|
|
|
|
if (!dialog) return;
|
|
|
|
|
async function cancelTransaction() {
|
|
|
|
|
const dialog = document.getElementById('transactionDialog');
|
|
|
|
|
dialog.classList.remove('show');
|
|
|
|
|
dialog.removeEventListener('keydown', onDialogKeydown);
|
|
|
|
|
const opener = document.querySelector('button[onclick="addTransaction()"]');
|
|
|
|
|
if (opener) opener.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logout() {
|
|
|
|
|
@ -454,31 +329,22 @@ function logout() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Global listeners
|
|
|
|
|
// Utils
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function attachGlobalHandlers() {
|
|
|
|
|
// Intercept form submissions for inline handlers in markup
|
|
|
|
|
const loginForm = qs('loginForm');
|
|
|
|
|
if (loginForm && !loginForm.__wired) {
|
|
|
|
|
loginForm.__wired = true;
|
|
|
|
|
loginForm.addEventListener('submit', (e) => { e.preventDefault(); login(); });
|
|
|
|
|
}
|
|
|
|
|
const registerForm = qs('registerForm');
|
|
|
|
|
if (registerForm && !registerForm.__wired) {
|
|
|
|
|
registerForm.__wired = true;
|
|
|
|
|
registerForm.addEventListener('submit', (e) => { e.preventDefault(); register(); });
|
|
|
|
|
}
|
|
|
|
|
const txForm = qs('transactionForm');
|
|
|
|
|
if (txForm && !txForm.__wired) {
|
|
|
|
|
txForm.__wired = true;
|
|
|
|
|
txForm.addEventListener('submit', (e) => { e.preventDefault(); confirmTransaction(); });
|
|
|
|
|
}
|
|
|
|
|
const cancelBtn = document.querySelector('#transactionDialog [data-dismiss]');
|
|
|
|
|
if (cancelBtn && !cancelBtn.__wired) {
|
|
|
|
|
cancelBtn.__wired = true;
|
|
|
|
|
cancelBtn.addEventListener('click', cancelTransaction);
|
|
|
|
|
function updateElement(id, textOrNode) {
|
|
|
|
|
const element = document.getElementById(id);
|
|
|
|
|
element.textContent = ''; // Removes all children
|
|
|
|
|
|
|
|
|
|
// START: FIX - Handle Text vs. Node/Fragment Appending Robustly
|
|
|
|
|
// When dealing with complex DOM nodes (like the DocumentFragment holding transactions),
|
|
|
|
|
// we must use appendChild. For simple text (like balance/error), textContent is fine.
|
|
|
|
|
if (textOrNode instanceof Node) {
|
|
|
|
|
element.appendChild(textOrNode);
|
|
|
|
|
} else {
|
|
|
|
|
element.textContent = textOrNode;
|
|
|
|
|
}
|
|
|
|
|
// END: FIX
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
@ -486,20 +352,16 @@ function attachGlobalHandlers() {
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function init() {
|
|
|
|
|
// Schema migration
|
|
|
|
|
migrateSchema();
|
|
|
|
|
|
|
|
|
|
// Restore active account
|
|
|
|
|
const saved = safeParse(localStorage.getItem(storageKey), null);
|
|
|
|
|
if (saved) updateState('account', saved);
|
|
|
|
|
|
|
|
|
|
// Seed history state if missing
|
|
|
|
|
if (!history.state || !history.state.path) {
|
|
|
|
|
const initialPath = state.account ? '/dashboard' : '/login';
|
|
|
|
|
history.replaceState({ path: initialPath }, '', initialPath);
|
|
|
|
|
// Restore state
|
|
|
|
|
const savedState = localStorage.getItem(storageKey);
|
|
|
|
|
if (savedState) {
|
|
|
|
|
updateState('account', JSON.parse(savedState));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initial route render
|
|
|
|
|
|
|
|
|
|
// applyThemeOnLoad() is now called via DOMContentLoaded and updateRoute
|
|
|
|
|
|
|
|
|
|
// Update route for browser back/next buttons
|
|
|
|
|
window.onpopstate = () => updateRoute();
|
|
|
|
|
updateRoute();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|