f827210a07
Three-tier user model: admin (all accounts, all actions), editor (assigned accounts, read/write), viewer (assigned accounts, read-only). Backend: - express-session with custom SQLite session store (no extra packages) - bcryptjs for password hashing - src/middleware/auth.js: requireAuth, requireAdmin, requireEditor, canAccessAccount helpers - src/routes/auth.js: login, logout, /me, setup-needed, change-password - src/routes/users.js: full CRUD + account assignments (admin only) - All API routes protected; /api/accounts filtered by user access; write routes gated by requireEditor; admin-only routes locked down Frontend: - Login overlay (full-page) with first-run admin-setup flow - Role-based UI: admin-only elements hidden for non-admins; edit/delete and PDF buttons hidden for viewers; account switcher shows only accessible accounts for non-admins - Users modal (admin only): user list with role badges, create/edit/delete users, set account access via checkboxes - Change-password section available to all logged-in users - apiFetch redirects to login on 401
1681 lines
66 KiB
JavaScript
1681 lines
66 KiB
JavaScript
'use strict';
|
|
|
|
const state = {
|
|
checks: [],
|
|
account: null,
|
|
accounts: [],
|
|
activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null,
|
|
filterStatus: '',
|
|
filterPayee: '',
|
|
filterDateFrom: '',
|
|
filterDateTo: '',
|
|
sortCol: 'check_no',
|
|
sortDir: 'desc',
|
|
selected: new Set(),
|
|
editingId: null,
|
|
user: null, // { id, username, role }
|
|
};
|
|
|
|
// ── API helpers ──────────────────────────────────────────────────────────────
|
|
|
|
async function apiFetch(method, path, body) {
|
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
const res = await fetch(path, opts);
|
|
if (res.status === 401) { showLoginOverlay(); return null; }
|
|
if (res.status === 204) return null;
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
return data;
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
function showLoginOverlay() {
|
|
document.getElementById('login-overlay').classList.remove('hidden');
|
|
}
|
|
|
|
function hideLoginOverlay() {
|
|
document.getElementById('login-overlay').classList.add('hidden');
|
|
}
|
|
|
|
async function checkAuth() {
|
|
// Is there already a session?
|
|
const res = await fetch('/api/auth/me');
|
|
if (res.ok) {
|
|
state.user = await res.json();
|
|
hideLoginOverlay();
|
|
applyRoleUI();
|
|
return true;
|
|
}
|
|
// No session — check if this is first-run (no users at all)
|
|
const setup = await fetch('/api/auth/setup-needed');
|
|
const { setupNeeded } = await setup.json();
|
|
if (setupNeeded) {
|
|
document.getElementById('login-setup-section').hidden = false;
|
|
document.getElementById('login-form-section').hidden = true;
|
|
} else {
|
|
document.getElementById('login-setup-section').hidden = true;
|
|
document.getElementById('login-form-section').hidden = false;
|
|
}
|
|
showLoginOverlay();
|
|
return false;
|
|
}
|
|
|
|
async function submitLogin() {
|
|
const username = document.getElementById('login-username').value.trim();
|
|
const password = document.getElementById('login-password').value;
|
|
const errEl = document.getElementById('login-error');
|
|
const btn = document.getElementById('btn-login-submit');
|
|
errEl.hidden = true;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Signing in…';
|
|
try {
|
|
state.user = await apiFetch('POST', '/api/auth/login', { username, password });
|
|
if (!state.user) return; // 401 already handled by apiFetch
|
|
hideLoginOverlay();
|
|
applyRoleUI();
|
|
await loadAccounts();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sign In';
|
|
}
|
|
}
|
|
|
|
async function submitSetup() {
|
|
const username = document.getElementById('setup-username').value.trim();
|
|
const password = document.getElementById('setup-password').value;
|
|
const password2 = document.getElementById('setup-password2').value;
|
|
const errEl = document.getElementById('setup-error');
|
|
const btn = document.getElementById('btn-setup-submit');
|
|
errEl.hidden = true;
|
|
if (password !== password2) { errEl.textContent = 'Passwords do not match.'; errEl.hidden = false; return; }
|
|
btn.disabled = true;
|
|
btn.textContent = 'Creating…';
|
|
try {
|
|
state.user = await apiFetch('POST', '/api/auth/setup', { username, password });
|
|
hideLoginOverlay();
|
|
applyRoleUI();
|
|
await loadAccounts();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Create Admin & Sign In';
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
state.user = null;
|
|
state.checks = [];
|
|
state.accounts = [];
|
|
state.account = null;
|
|
state.activeAccountId = null;
|
|
document.getElementById('login-username').value = '';
|
|
document.getElementById('login-password').value = '';
|
|
document.getElementById('login-error').hidden = true;
|
|
document.getElementById('login-setup-section').hidden = true;
|
|
document.getElementById('login-form-section').hidden = false;
|
|
showLoginOverlay();
|
|
}
|
|
|
|
// Hide/show elements based on role
|
|
function applyRoleUI() {
|
|
const role = state.user ? state.user.role : 'viewer';
|
|
const isAdmin = role === 'admin';
|
|
const isEditor = role === 'admin' || role === 'editor';
|
|
|
|
document.getElementById('header-username').textContent = state.user ? state.user.username : '';
|
|
|
|
// Admin-only elements
|
|
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
|
|
|
|
// Editor+ elements (hide for viewers)
|
|
document.querySelectorAll('[data-editor-only]').forEach(el => { el.hidden = !isEditor; });
|
|
|
|
// Users button (admin only)
|
|
document.getElementById('btn-users').hidden = !isAdmin;
|
|
}
|
|
|
|
// ── User management ────────────────────────────────────────────────────────────
|
|
|
|
let usersState = { users: [], editingId: null };
|
|
|
|
function openUsersModal() {
|
|
document.getElementById('user-form-error').hidden = true;
|
|
document.getElementById('users-overlay').classList.add('open');
|
|
document.getElementById('users-modal').classList.add('open');
|
|
loadUsers();
|
|
renderUfAccountCheckboxes();
|
|
}
|
|
|
|
function closeUsersModal() {
|
|
document.getElementById('users-overlay').classList.remove('open');
|
|
document.getElementById('users-modal').classList.remove('open');
|
|
cancelUserEdit();
|
|
}
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
usersState.users = await apiFetch('GET', '/api/users');
|
|
renderUsersList();
|
|
} catch (err) {
|
|
document.getElementById('users-list').innerHTML =
|
|
`<p style="color:var(--danger)">${escHtml(err.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function roleBadge(role) {
|
|
const colors = { admin: '#2563eb', editor: '#16a34a', viewer: '#6b7280' };
|
|
return `<span style="background:${colors[role]};color:#fff;font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px;text-transform:uppercase">${role}</span>`;
|
|
}
|
|
|
|
function renderUsersList() {
|
|
const el = document.getElementById('users-list');
|
|
const { users } = usersState;
|
|
if (!users.length) { el.innerHTML = '<p style="color:var(--text-muted)">No users.</p>'; return; }
|
|
|
|
el.innerHTML = `<table class="qbo-preview-table" style="width:100%">
|
|
<thead><tr><th>Username</th><th>Role</th><th>Account Access</th><th></th></tr></thead>
|
|
<tbody>
|
|
${users.map(u => {
|
|
const isSelf = u.id === state.user.id;
|
|
const accountsLabel = u.role === 'admin'
|
|
? '<em style="color:var(--text-muted)">All accounts</em>'
|
|
: (u.accounts.length ? u.accounts.map(aid => {
|
|
const a = state.accounts.find(x => x.id === aid);
|
|
return escHtml(a ? (a.company1 || `Account ${a.id}`) : `#${aid}`);
|
|
}).join(', ') : '<em style="color:var(--text-muted)">None</em>');
|
|
return `<tr>
|
|
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}</td>
|
|
<td>${roleBadge(u.role)}</td>
|
|
<td style="font-size:12px">${accountsLabel}</td>
|
|
<td style="white-space:nowrap">
|
|
<button class="btn-sm btn-secondary" onclick="startUserEdit(${u.id})">Edit</button>
|
|
${!isSelf ? `<button class="btn-sm btn-danger" style="margin-left:4px" onclick="deleteUser(${u.id})">Delete</button>` : ''}
|
|
</td>
|
|
</tr>`;
|
|
}).join('')}
|
|
</tbody></table>`;
|
|
}
|
|
|
|
function renderUfAccountCheckboxes() {
|
|
const role = document.getElementById('uf-role').value;
|
|
const group = document.getElementById('uf-accounts-group');
|
|
group.hidden = role === 'admin';
|
|
const container = document.getElementById('uf-accounts-checkboxes');
|
|
const currentAccounts = usersState.editingId
|
|
? (usersState.users.find(u => u.id === usersState.editingId) || {}).accounts || []
|
|
: [];
|
|
container.innerHTML = state.accounts.map(a =>
|
|
`<label class="account-checkbox-label">
|
|
<input type="checkbox" name="uf-account" value="${a.id}"${currentAccounts.includes(a.id) ? ' checked' : ''}>
|
|
${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}
|
|
</label>`
|
|
).join('');
|
|
}
|
|
|
|
function startUserEdit(userId) {
|
|
const u = usersState.users.find(x => x.id === userId);
|
|
if (!u) return;
|
|
usersState.editingId = userId;
|
|
document.getElementById('user-form-title').textContent = `Edit User: ${u.username}`;
|
|
document.getElementById('uf-username').value = u.username;
|
|
document.getElementById('uf-password').value = '';
|
|
document.getElementById('uf-password-hint').textContent = '(leave blank to keep)';
|
|
document.getElementById('uf-role').value = u.role;
|
|
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
|
document.getElementById('btn-cancel-user-edit').hidden = false;
|
|
document.getElementById('user-form-error').hidden = true;
|
|
renderUfAccountCheckboxes();
|
|
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function cancelUserEdit() {
|
|
usersState.editingId = null;
|
|
document.getElementById('user-form-title').textContent = 'Add User';
|
|
document.getElementById('uf-username').value = '';
|
|
document.getElementById('uf-password').value = '';
|
|
document.getElementById('uf-password-hint').textContent = '(min 8 chars)';
|
|
document.getElementById('uf-role').value = 'viewer';
|
|
document.getElementById('btn-save-user').textContent = 'Add User';
|
|
document.getElementById('btn-cancel-user-edit').hidden = true;
|
|
document.getElementById('user-form-error').hidden = true;
|
|
renderUfAccountCheckboxes();
|
|
}
|
|
|
|
async function saveUser() {
|
|
const errEl = document.getElementById('user-form-error');
|
|
const btn = document.getElementById('btn-save-user');
|
|
errEl.hidden = true;
|
|
const username = document.getElementById('uf-username').value.trim();
|
|
const password = document.getElementById('uf-password').value;
|
|
const role = document.getElementById('uf-role').value;
|
|
const accounts = Array.from(document.querySelectorAll('input[name="uf-account"]:checked'))
|
|
.map(cb => parseInt(cb.value, 10));
|
|
|
|
if (!username) { errEl.textContent = 'Username required.'; errEl.hidden = false; return; }
|
|
if (!usersState.editingId && !password) { errEl.textContent = 'Password required.'; errEl.hidden = false; return; }
|
|
|
|
btn.disabled = true;
|
|
const origText = btn.textContent;
|
|
btn.textContent = 'Saving…';
|
|
try {
|
|
const body = { username, role, accounts };
|
|
if (password) body.password = password;
|
|
if (usersState.editingId) {
|
|
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
|
} else {
|
|
await apiFetch('POST', '/api/users', body);
|
|
}
|
|
cancelUserEdit();
|
|
await loadUsers();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = origText;
|
|
}
|
|
}
|
|
|
|
async function deleteUser(userId) {
|
|
const u = usersState.users.find(x => x.id === userId);
|
|
if (!u) return;
|
|
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
|
|
try {
|
|
await apiFetch('DELETE', `/api/users/${userId}`);
|
|
if (usersState.editingId === userId) cancelUserEdit();
|
|
await loadUsers();
|
|
} catch (err) {
|
|
alert('Delete failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function changeOwnPassword() {
|
|
const errEl = document.getElementById('cp-error');
|
|
const successEl = document.getElementById('cp-success');
|
|
const btn = document.getElementById('btn-change-password');
|
|
errEl.hidden = true;
|
|
successEl.hidden = true;
|
|
const current = document.getElementById('cp-current').value;
|
|
const next = document.getElementById('cp-new').value;
|
|
const confirm2 = document.getElementById('cp-confirm').value;
|
|
if (next !== confirm2) { errEl.textContent = 'New passwords do not match.'; errEl.hidden = false; return; }
|
|
btn.disabled = true;
|
|
try {
|
|
await apiFetch('POST', '/api/auth/change-password', { current_password: current, new_password: next });
|
|
document.getElementById('cp-current').value = '';
|
|
document.getElementById('cp-new').value = '';
|
|
document.getElementById('cp-confirm').value = '';
|
|
successEl.hidden = false;
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ── Data loading ─────────────────────────────────────────────────────────────
|
|
|
|
async function loadAccounts() {
|
|
try {
|
|
state.accounts = await apiFetch('GET', '/api/accounts');
|
|
if (!state.accounts) return; // 401 redirect handled by apiFetch
|
|
if (state.accounts.length === 0) {
|
|
// Only admins can create accounts; non-admins just see an empty state
|
|
if (state.user && state.user.role === 'admin') openWizard();
|
|
return;
|
|
}
|
|
// Use stored account or default to first
|
|
const stored = state.activeAccountId;
|
|
const valid = stored && state.accounts.find(a => a.id === stored);
|
|
state.activeAccountId = valid ? stored : state.accounts[0].id;
|
|
localStorage.setItem('activeAccountId', state.activeAccountId);
|
|
|
|
populateAccountSwitcher();
|
|
state.account = await apiFetch('GET', `/api/account/${state.activeAccountId}`);
|
|
renderHeader();
|
|
await loadChecks();
|
|
} catch (err) {
|
|
console.error('Failed to load accounts:', err);
|
|
}
|
|
}
|
|
|
|
function populateAccountSwitcher() {
|
|
const sel = document.getElementById('account-switcher');
|
|
sel.innerHTML = state.accounts.map(a =>
|
|
`<option value="${a.id}"${a.id === state.activeAccountId ? ' selected' : ''}>${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}</option>`
|
|
).join('');
|
|
}
|
|
|
|
async function switchAccount(accountId) {
|
|
state.activeAccountId = accountId;
|
|
localStorage.setItem('activeAccountId', accountId);
|
|
state.selected.clear();
|
|
state.account = await apiFetch('GET', `/api/account/${accountId}`);
|
|
renderHeader();
|
|
await loadChecks();
|
|
}
|
|
|
|
async function loadChecks() {
|
|
if (!state.activeAccountId) return;
|
|
const tbody = document.getElementById('checks-tbody');
|
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
|
|
try {
|
|
state.checks = await apiFetch('GET', `/api/checks?account_id=${state.activeAccountId}`);
|
|
state.selected.clear();
|
|
renderTable();
|
|
refreshPdfButton();
|
|
} catch (err) {
|
|
tbody.innerHTML = `<tr class="empty-row"><td colspan="8">Error loading checks: ${escHtml(err.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
|
|
function renderHeader() {
|
|
const a = state.account;
|
|
if (!a) return;
|
|
document.getElementById('company-name').textContent = a.company1 || 'ezcheck';
|
|
document.getElementById('current-check-no').textContent = a.current_check_no + 1;
|
|
}
|
|
|
|
function renderTable() {
|
|
const checks = filteredAndSortedChecks();
|
|
const tbody = document.getElementById('checks-tbody');
|
|
|
|
if (checks.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No checks found.</td></tr>';
|
|
updateSortIndicators();
|
|
updateSelectAll();
|
|
updateChecksSummary();
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = checks.map(renderRow).join('');
|
|
updateSortIndicators();
|
|
updateSelectAll();
|
|
updateChecksSummary();
|
|
|
|
// Attach row-level event listeners
|
|
tbody.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
cb.addEventListener('change', () => onCheckboxChange(cb));
|
|
});
|
|
tbody.querySelectorAll('.btn-edit').forEach(btn => {
|
|
btn.addEventListener('click', () => openPanel(parseInt(btn.dataset.id, 10)));
|
|
});
|
|
tbody.querySelectorAll('.btn-delete').forEach(btn => {
|
|
btn.addEventListener('click', () => deleteCheck(parseInt(btn.dataset.id, 10)));
|
|
});
|
|
}
|
|
|
|
function renderRow(c) {
|
|
const printed = !!c.printed;
|
|
const selected = state.selected.has(c.id);
|
|
|
|
const fmtAmount = new Intl.NumberFormat('en-US', {
|
|
style: 'currency', currency: 'USD',
|
|
}).format(c.amount);
|
|
|
|
const fmtDate = c.check_date
|
|
? new Date(c.check_date + 'T12:00:00').toLocaleDateString('en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric',
|
|
})
|
|
: '—';
|
|
|
|
const checkbox = isEditor
|
|
? `<td class="col-select"><input type="checkbox" data-id="${c.id}"${selected ? ' checked' : ''}></td>`
|
|
: `<td class="col-select"></td>`;
|
|
|
|
const statusBadge = printed
|
|
? '<span class="status-badge status-printed">Printed</span>'
|
|
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
|
|
|
const isEditor = state.user && (state.user.role === 'admin' || state.user.role === 'editor');
|
|
const actions = isEditor
|
|
? `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
|
|
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`
|
|
: '';
|
|
|
|
return `<tr class="${printed ? 'printed' : ''}">
|
|
${checkbox}
|
|
<td class="col-no">${c.check_no}</td>
|
|
<td class="col-date">${fmtDate}</td>
|
|
<td class="col-payee">${escHtml(c.payee)}</td>
|
|
<td class="col-amount">${fmtAmount}</td>
|
|
<td class="col-memo" title="${escHtml(c.memo || '')}">${escHtml(c.memo || '')}</td>
|
|
<td class="col-status">${statusBadge}</td>
|
|
<td class="col-actions">${actions}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function filteredAndSortedChecks() {
|
|
const payee = state.filterPayee.toLowerCase();
|
|
const from = state.filterDateFrom;
|
|
const to = state.filterDateTo;
|
|
const status = state.filterStatus;
|
|
|
|
let list = state.checks.filter(c => {
|
|
if (payee && !c.payee.toLowerCase().includes(payee)) return false;
|
|
if (from && c.check_date < from) return false;
|
|
if (to && c.check_date > to) return false;
|
|
if (status === '0' && c.printed) return false;
|
|
if (status === '1' && !c.printed) return false;
|
|
return true;
|
|
});
|
|
|
|
const col = state.sortCol;
|
|
const dir = state.sortDir === 'asc' ? 1 : -1;
|
|
return list.sort((a, b) => {
|
|
let av = a[col];
|
|
let bv = b[col];
|
|
if (col === 'amount') { av = parseFloat(av); bv = parseFloat(bv); }
|
|
if (av == null) return 1;
|
|
if (bv == null) return -1;
|
|
if (av < bv) return -dir;
|
|
if (av > bv) return dir;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function updateSortIndicators() {
|
|
document.querySelectorAll('thead th.sortable').forEach(th => {
|
|
th.classList.remove('sort-asc', 'sort-desc');
|
|
if (th.dataset.col === state.sortCol) {
|
|
th.classList.add(state.sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateSelectAll() {
|
|
const selectAll = document.getElementById('select-all-checks');
|
|
const checks = filteredAndSortedChecks();
|
|
if (checks.length === 0) {
|
|
selectAll.checked = false;
|
|
selectAll.indeterminate = false;
|
|
return;
|
|
}
|
|
const nSelected = checks.filter(c => state.selected.has(c.id)).length;
|
|
selectAll.indeterminate = nSelected > 0 && nSelected < checks.length;
|
|
selectAll.checked = nSelected === checks.length;
|
|
}
|
|
|
|
function updateChecksSummary() {
|
|
const el = document.getElementById('checks-summary');
|
|
const filtered = filteredAndSortedChecks();
|
|
const all = state.checks.length;
|
|
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
|
|
|
if (all === 0) { el.textContent = ''; return; }
|
|
|
|
const filteredTotal = filtered.reduce((s, c) => s + (parseFloat(c.amount) || 0), 0);
|
|
const isFiltered = filtered.length < all;
|
|
if (isFiltered) {
|
|
el.textContent = `${filtered.length} of ${all} checks · ${fmt(filteredTotal)}`;
|
|
} else {
|
|
el.textContent = `${all} check${all !== 1 ? 's' : ''} · ${fmt(filteredTotal)}`;
|
|
}
|
|
}
|
|
|
|
|
|
function refreshPdfButton() {
|
|
const n = state.selected.size;
|
|
const btn = document.getElementById('btn-generate-pdf');
|
|
btn.disabled = n === 0;
|
|
document.getElementById('selected-count').textContent = n;
|
|
}
|
|
|
|
// ── Checkbox handling ────────────────────────────────────────────────────────
|
|
|
|
function onCheckboxChange(cb) {
|
|
const id = parseInt(cb.dataset.id, 10);
|
|
if (cb.checked) {
|
|
state.selected.add(id);
|
|
} else {
|
|
state.selected.delete(id);
|
|
}
|
|
refreshPdfButton();
|
|
updateSelectAll();
|
|
}
|
|
|
|
// ── Slide-in panel ───────────────────────────────────────────────────────────
|
|
|
|
function openPanel(id = null) {
|
|
state.editingId = id;
|
|
const form = document.getElementById('check-form');
|
|
const title = document.getElementById('panel-title');
|
|
|
|
form.reset();
|
|
clearFormErrors();
|
|
document.querySelector('.address-section').removeAttribute('open');
|
|
|
|
if (id !== null) {
|
|
const check = state.checks.find(c => c.id === id);
|
|
if (!check) return;
|
|
title.textContent = `Edit Check #${check.check_no}`;
|
|
form.payee.value = check.payee || '';
|
|
form.amount.value = check.amount != null ? check.amount : '';
|
|
form.check_date.value = check.check_date || '';
|
|
form.memo.value = check.memo || '';
|
|
form.note1.value = check.note1 || '';
|
|
form.note2.value = check.note2 || '';
|
|
form.payee_address1.value = check.payee_address1 || '';
|
|
form.payee_address2.value = check.payee_address2 || '';
|
|
form.payee_address3.value = check.payee_address3 || '';
|
|
form.payee_address4.value = check.payee_address4 || '';
|
|
if (check.payee_address1) {
|
|
document.querySelector('.address-section').setAttribute('open', '');
|
|
}
|
|
} else {
|
|
title.textContent = 'New Check';
|
|
form.check_date.value = new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
document.getElementById('panel-overlay').classList.add('open');
|
|
document.getElementById('check-panel').classList.add('open');
|
|
form.payee.focus();
|
|
}
|
|
|
|
function closePanel() {
|
|
document.getElementById('panel-overlay').classList.remove('open');
|
|
document.getElementById('check-panel').classList.remove('open');
|
|
state.editingId = null;
|
|
}
|
|
|
|
function clearFormErrors() {
|
|
document.querySelectorAll('#check-form .error').forEach(el => el.classList.remove('error'));
|
|
}
|
|
|
|
// ── CRUD actions ─────────────────────────────────────────────────────────────
|
|
|
|
async function saveCheck(e) {
|
|
e.preventDefault();
|
|
clearFormErrors();
|
|
|
|
const form = e.target;
|
|
const data = {
|
|
payee: form.payee.value.trim(),
|
|
amount: parseFloat(form.amount.value),
|
|
check_date: form.check_date.value,
|
|
memo: form.memo.value.trim() || null,
|
|
note1: form.note1.value.trim() || null,
|
|
note2: form.note2.value.trim() || null,
|
|
payee_address1: form.payee_address1.value.trim() || null,
|
|
payee_address2: form.payee_address2.value.trim() || null,
|
|
payee_address3: form.payee_address3.value.trim() || null,
|
|
payee_address4: form.payee_address4.value.trim() || null,
|
|
};
|
|
|
|
let valid = true;
|
|
if (!data.payee) { form.payee.classList.add('error'); valid = false; }
|
|
if (!data.amount || isNaN(data.amount) || data.amount <= 0) { form.amount.classList.add('error'); valid = false; }
|
|
if (!data.check_date) { form.check_date.classList.add('error'); valid = false; }
|
|
if (!valid) return;
|
|
|
|
const btn = document.getElementById('btn-save');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving…';
|
|
|
|
try {
|
|
if (state.editingId !== null) {
|
|
await apiFetch('PUT', `/api/checks/${state.editingId}`, data);
|
|
} else {
|
|
await apiFetch('POST', '/api/checks', { ...data, account_id: state.activeAccountId });
|
|
}
|
|
closePanel();
|
|
await Promise.all([loadAccounts(), loadChecks()]);
|
|
} catch (err) {
|
|
alert(`Error: ${err.message}`);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save Check';
|
|
}
|
|
}
|
|
|
|
async function deleteCheck(id) {
|
|
const check = state.checks.find(c => c.id === id);
|
|
if (!check) return;
|
|
if (!confirm(`Delete check #${check.check_no} payable to "${check.payee}"?`)) return;
|
|
try {
|
|
await apiFetch('DELETE', `/api/checks/${id}`);
|
|
await loadChecks();
|
|
} catch (err) {
|
|
alert(`Error: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function generatePdf() {
|
|
const ids = [...state.selected];
|
|
if (ids.length === 0) return;
|
|
|
|
const btn = document.getElementById('btn-generate-pdf');
|
|
btn.disabled = true;
|
|
const countSpan = document.getElementById('selected-count');
|
|
const savedCount = countSpan.textContent;
|
|
countSpan.textContent = '…';
|
|
|
|
try {
|
|
const res = await fetch('/api/pdf', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ checkIds: ids, account_id: state.activeAccountId }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || res.statusText);
|
|
}
|
|
const blob = await res.blob();
|
|
window.open(URL.createObjectURL(blob), '_blank');
|
|
await loadChecks(); // refresh to show printed status
|
|
} catch (err) {
|
|
countSpan.textContent = savedCount;
|
|
btn.disabled = false;
|
|
alert(`PDF error: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
|
|
// ── Setup wizard ─────────────────────────────────────────────────────────────
|
|
|
|
const wizard = { step: 1, logoData: null };
|
|
|
|
function openWizard() {
|
|
wizard.step = 1;
|
|
wizard.logoData = null;
|
|
document.getElementById('w-logo').value = '';
|
|
document.getElementById('wizard-error').hidden = true;
|
|
goToWizardStep(1);
|
|
document.getElementById('wizard-overlay').classList.add('open');
|
|
document.getElementById('wizard-modal').classList.add('open');
|
|
document.getElementById('w-company1').focus();
|
|
}
|
|
|
|
function closeWizard() {
|
|
document.getElementById('wizard-overlay').classList.remove('open');
|
|
document.getElementById('wizard-modal').classList.remove('open');
|
|
}
|
|
|
|
function goToWizardStep(n) {
|
|
wizard.step = n;
|
|
[1, 2, 3].forEach(i => {
|
|
document.getElementById(`wizard-step-${i}`).hidden = i !== n;
|
|
const dot = document.querySelector(`.wizard-step-dot[data-step="${i}"]`);
|
|
dot.classList.toggle('active', i === n);
|
|
dot.classList.toggle('done', i < n);
|
|
});
|
|
document.querySelectorAll('.wizard-step-line').forEach((line, idx) => {
|
|
line.classList.toggle('done', idx < n - 1);
|
|
});
|
|
document.getElementById('btn-wizard-prev').hidden = n === 1;
|
|
document.getElementById('btn-wizard-next').hidden = n === 3;
|
|
document.getElementById('btn-wizard-finish').hidden = n !== 3;
|
|
document.getElementById('wizard-error').hidden = true;
|
|
}
|
|
|
|
function validateWizardStep() {
|
|
const err = document.getElementById('wizard-error');
|
|
if (wizard.step === 1) {
|
|
if (!document.getElementById('w-company1').value.trim()) {
|
|
err.textContent = 'Organization name is required.';
|
|
err.hidden = false;
|
|
document.getElementById('w-company1').focus();
|
|
return false;
|
|
}
|
|
}
|
|
if (wizard.step === 2) {
|
|
if (!document.getElementById('w-bank-name').value.trim()) {
|
|
err.textContent = 'Bank name is required.';
|
|
err.hidden = false;
|
|
document.getElementById('w-bank-name').focus();
|
|
return false;
|
|
}
|
|
}
|
|
if (wizard.step === 3) {
|
|
const routing = document.getElementById('w-routing').value.trim();
|
|
const account = document.getElementById('w-account').value.trim();
|
|
const startNo = document.getElementById('w-start-check').value.trim();
|
|
if (!routing) { err.textContent = 'Routing number is required.'; err.hidden = false; return false; }
|
|
if (!account) { err.textContent = 'Account number is required.'; err.hidden = false; return false; }
|
|
if (!startNo || parseInt(startNo, 10) < 1) { err.textContent = 'Starting check number is required.'; err.hidden = false; return false; }
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function finishWizard() {
|
|
if (!validateWizardStep()) return;
|
|
|
|
const city = document.getElementById('w-city').value.trim();
|
|
const state_ = document.getElementById('w-state').value.trim().toUpperCase();
|
|
const zip = document.getElementById('w-zip').value.trim();
|
|
const cityLine = [city, state_ ? (zip ? `${state_} ${zip}` : state_) : zip].filter(Boolean).join(', ');
|
|
|
|
const payload = {
|
|
company1: document.getElementById('w-company1').value.trim(),
|
|
company2: document.getElementById('w-addr1').value.trim() || null,
|
|
company3: cityLine || null,
|
|
company4: document.getElementById('w-contact').value.trim() || null,
|
|
bank_name: document.getElementById('w-bank-name').value.trim(),
|
|
bank_info1: document.getElementById('w-bank-addr').value.trim() || null,
|
|
bank_info2: document.getElementById('w-bank-contact').value.trim() || null,
|
|
transit_code: document.getElementById('w-transit').value.trim() || null,
|
|
routing_number: document.getElementById('w-routing').value.trim(),
|
|
account_number: document.getElementById('w-account').value.trim(),
|
|
start_check_no: parseInt(document.getElementById('w-start-check').value, 10),
|
|
logo_data: wizard.logoData || null,
|
|
};
|
|
|
|
const btn = document.getElementById('btn-wizard-finish');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving…';
|
|
|
|
try {
|
|
const result = await apiFetch('POST', '/api/account/setup', payload);
|
|
closeWizard();
|
|
await loadAccounts();
|
|
if (result.accountId) await switchAccount(result.accountId);
|
|
} catch (err) {
|
|
const errEl = document.getElementById('wizard-error');
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save & Start';
|
|
}
|
|
}
|
|
|
|
// ── Import modal ─────────────────────────────────────────────────────────────
|
|
|
|
function openImportModal() {
|
|
document.getElementById('import-file').value = '';
|
|
const log = document.getElementById('import-log');
|
|
log.hidden = true;
|
|
log.textContent = '';
|
|
log.className = 'import-log';
|
|
document.getElementById('btn-run-import').disabled = false;
|
|
document.getElementById('btn-run-import').textContent = 'Import';
|
|
document.getElementById('import-modal-overlay').classList.add('open');
|
|
document.getElementById('import-modal').classList.add('open');
|
|
}
|
|
|
|
function closeImportModal() {
|
|
document.getElementById('import-modal-overlay').classList.remove('open');
|
|
document.getElementById('import-modal').classList.remove('open');
|
|
}
|
|
|
|
async function runImport() {
|
|
const fileInput = document.getElementById('import-file');
|
|
if (!fileInput.files.length) {
|
|
alert('Select an .mdb file first.');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btn-run-import');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Importing…';
|
|
|
|
const log = document.getElementById('import-log');
|
|
log.hidden = false;
|
|
log.className = 'import-log';
|
|
log.textContent = 'Running import…';
|
|
|
|
const form = new FormData();
|
|
form.append('mdbfile', fileInput.files[0]);
|
|
|
|
try {
|
|
const res = await fetch('/api/import', { method: 'POST', body: form });
|
|
const data = await res.json();
|
|
log.textContent = data.log || '';
|
|
if (res.ok) {
|
|
log.classList.add('success');
|
|
btn.textContent = 'Done';
|
|
await loadAccounts();
|
|
if (data.newAccountId) await switchAccount(data.newAccountId);
|
|
} else {
|
|
log.classList.add('error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Retry';
|
|
}
|
|
} catch (err) {
|
|
log.classList.add('error');
|
|
log.textContent = err.message;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Retry';
|
|
}
|
|
}
|
|
|
|
// ── Account settings modal ───────────────────────────────────────────────────
|
|
|
|
const acctSettings = { logoData: null };
|
|
|
|
function openAccountSettings() {
|
|
const a = state.account;
|
|
if (!a) return;
|
|
|
|
acctSettings.logoData = null;
|
|
|
|
const f = document.getElementById('acct-settings-form');
|
|
f.elements.company1.value = a.company1 || '';
|
|
f.elements.company2.value = a.company2 || '';
|
|
f.elements.company3.value = a.company3 || '';
|
|
f.elements.company4.value = a.company4 || '';
|
|
f.elements.bank_name.value = a.bank_name || '';
|
|
f.elements.bank_info1.value = a.bank_info1 || '';
|
|
f.elements.bank_info2.value = a.bank_info2 || '';
|
|
f.elements.transit_code.value = a.transit_code || '';
|
|
f.elements.routing_number.value = a.routing_number || '';
|
|
f.elements.account_number.value = a.account_number || '';
|
|
f.elements.offset_left.value = a.offset_left || 0;
|
|
f.elements.offset_right.value = a.offset_right || 0;
|
|
f.elements.offset_up.value = a.offset_up || 0;
|
|
f.elements.offset_down.value = a.offset_down || 0;
|
|
document.getElementById('as-second-sig').checked = !!a.second_signature;
|
|
|
|
document.getElementById('as-logo').value = '';
|
|
document.getElementById('as-logo-preview').hidden = true;
|
|
document.getElementById('acct-settings-error').hidden = true;
|
|
document.getElementById('btn-save-acct-settings').disabled = false;
|
|
document.getElementById('btn-save-acct-settings').textContent = 'Save Changes';
|
|
|
|
document.getElementById('acct-settings-overlay').classList.add('open');
|
|
document.getElementById('acct-settings-modal').classList.add('open');
|
|
f.elements.company1.focus();
|
|
}
|
|
|
|
function closeAccountSettings() {
|
|
document.getElementById('acct-settings-overlay').classList.remove('open');
|
|
document.getElementById('acct-settings-modal').classList.remove('open');
|
|
}
|
|
|
|
async function saveAccountSettings() {
|
|
const f = document.getElementById('acct-settings-form');
|
|
const errEl = document.getElementById('acct-settings-error');
|
|
errEl.hidden = true;
|
|
|
|
const payload = {
|
|
company1: f.elements.company1.value.trim(),
|
|
company2: f.elements.company2.value.trim() || null,
|
|
company3: f.elements.company3.value.trim() || null,
|
|
company4: f.elements.company4.value.trim() || null,
|
|
bank_name: f.elements.bank_name.value.trim(),
|
|
bank_info1: f.elements.bank_info1.value.trim() || null,
|
|
bank_info2: f.elements.bank_info2.value.trim() || null,
|
|
transit_code: f.elements.transit_code.value.trim() || null,
|
|
routing_number: f.elements.routing_number.value.trim(),
|
|
account_number: f.elements.account_number.value.trim(),
|
|
offset_left: parseFloat(f.elements.offset_left.value) || 0,
|
|
offset_right: parseFloat(f.elements.offset_right.value) || 0,
|
|
offset_up: parseFloat(f.elements.offset_up.value) || 0,
|
|
offset_down: parseFloat(f.elements.offset_down.value) || 0,
|
|
second_signature: document.getElementById('as-second-sig').checked ? 1 : 0,
|
|
logo_data: acctSettings.logoData || null,
|
|
};
|
|
|
|
if (!payload.company1) {
|
|
errEl.textContent = 'Organization name is required.';
|
|
errEl.hidden = false;
|
|
f.elements.company1.focus();
|
|
return;
|
|
}
|
|
if (!payload.routing_number || !payload.account_number) {
|
|
errEl.textContent = 'Routing number and account number are required.';
|
|
errEl.hidden = false;
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('btn-save-acct-settings');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving…';
|
|
|
|
try {
|
|
state.account = await apiFetch('PUT', `/api/account/${state.activeAccountId}`, payload);
|
|
// Refresh account in the accounts list (for the switcher label)
|
|
await loadAccounts();
|
|
renderHeader();
|
|
closeAccountSettings();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save Changes';
|
|
}
|
|
}
|
|
|
|
// ── Delete account ────────────────────────────────────────────────────────────
|
|
|
|
function openDeleteAccount() {
|
|
const name = (state.account && state.account.company1) || 'this account';
|
|
document.getElementById('delete-account-name').textContent = name;
|
|
document.getElementById('delete-account-overlay').classList.add('open');
|
|
document.getElementById('delete-account-modal').classList.add('open');
|
|
}
|
|
|
|
function closeDeleteAccount() {
|
|
document.getElementById('delete-account-overlay').classList.remove('open');
|
|
document.getElementById('delete-account-modal').classList.remove('open');
|
|
}
|
|
|
|
async function confirmDeleteAccount() {
|
|
const btn = document.getElementById('btn-confirm-delete-account');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Deleting…';
|
|
try {
|
|
await apiFetch('DELETE', `/api/account/${state.activeAccountId}`);
|
|
closeDeleteAccount();
|
|
closeAccountSettings();
|
|
state.account = null;
|
|
state.activeAccountId = null;
|
|
state.checks = [];
|
|
localStorage.removeItem('activeAccountId');
|
|
await loadAccounts(); // will open wizard if no accounts remain
|
|
} catch (err) {
|
|
alert('Delete failed: ' + err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Yes, Delete Account';
|
|
}
|
|
}
|
|
|
|
// ── QBO Import ────────────────────────────────────────────────────────────────
|
|
|
|
let qboChecksRecords = null;
|
|
let qboDepositsRecords = null;
|
|
|
|
function openQboImport(tab) {
|
|
switchQboTab(tab || 'checks');
|
|
resetQboPane('checks');
|
|
resetQboPane('deposits');
|
|
document.getElementById('qbo-import-overlay').classList.add('open');
|
|
document.getElementById('qbo-import-modal').classList.add('open');
|
|
}
|
|
|
|
function closeQboImport() {
|
|
document.getElementById('qbo-import-overlay').classList.remove('open');
|
|
document.getElementById('qbo-import-modal').classList.remove('open');
|
|
}
|
|
|
|
function resetQboPane(type) {
|
|
document.getElementById(`qbo-${type}-file`).value = '';
|
|
document.getElementById(`qbo-${type}-preview`).hidden = true;
|
|
document.getElementById(`qbo-${type}-preview`).innerHTML = '';
|
|
document.getElementById(`qbo-${type}-result`).hidden = true;
|
|
document.getElementById(`qbo-${type}-result`).textContent = '';
|
|
document.getElementById(`qbo-${type}-error`).hidden = true;
|
|
document.getElementById(`qbo-${type}-error`).textContent = '';
|
|
document.getElementById(`btn-qbo-${type}-import`).hidden = true;
|
|
document.getElementById(`btn-qbo-${type}-import`).disabled = true;
|
|
if (type === 'checks') qboChecksRecords = null;
|
|
else qboDepositsRecords = null;
|
|
}
|
|
|
|
function switchQboTab(tab) {
|
|
document.querySelectorAll('.qbo-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
|
document.getElementById('qbo-pane-checks').hidden = tab !== 'checks';
|
|
document.getElementById('qbo-pane-deposits').hidden = tab !== 'deposits';
|
|
}
|
|
|
|
async function qboParseFile(type) {
|
|
const fileInput = document.getElementById(`qbo-${type}-file`);
|
|
const errEl = document.getElementById(`qbo-${type}-error`);
|
|
const previewEl = document.getElementById(`qbo-${type}-preview`);
|
|
const resultEl = document.getElementById(`qbo-${type}-result`);
|
|
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
|
|
const parseBtn = document.getElementById(`btn-qbo-${type}-parse`);
|
|
|
|
errEl.hidden = true;
|
|
previewEl.hidden = true;
|
|
previewEl.innerHTML = '';
|
|
resultEl.hidden = true;
|
|
importBtn.hidden = true;
|
|
importBtn.disabled = true;
|
|
|
|
const file = fileInput.files[0];
|
|
if (!file) { errEl.textContent = 'Select a CSV file first.'; errEl.hidden = false; return; }
|
|
|
|
parseBtn.disabled = true;
|
|
parseBtn.textContent = 'Parsing\u2026';
|
|
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
fd.append('type', type);
|
|
const resp = await fetch('/api/qbo-import/parse', { method: 'POST', body: fd });
|
|
const data = await resp.json();
|
|
if (!resp.ok) throw new Error(data.error || 'Parse failed');
|
|
|
|
if (type === 'checks') {
|
|
qboChecksRecords = data.records;
|
|
previewEl.innerHTML = buildChecksPreviewHTML(data.records, data.warnings);
|
|
} else {
|
|
qboDepositsRecords = data.records;
|
|
previewEl.innerHTML = buildDepositsPreviewHTML(data.records, data.warnings);
|
|
}
|
|
previewEl.hidden = false;
|
|
const depCount = type === 'deposits' ? countDepositDates(data.records) : 0;
|
|
importBtn.textContent = type === 'checks'
|
|
? `Import ${data.records.length} Check${data.records.length !== 1 ? 's' : ''}`
|
|
: `Import ${depCount} Deposit${depCount !== 1 ? 's' : ''}`;
|
|
importBtn.hidden = false;
|
|
importBtn.disabled = false;
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
parseBtn.disabled = false;
|
|
parseBtn.textContent = 'Preview';
|
|
}
|
|
}
|
|
|
|
function countDepositDates(records) {
|
|
return new Set(records.map(r => r.date)).size;
|
|
}
|
|
|
|
const fmtCurrency = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
|
const fmtDateDisp = iso => { const [y, m, d] = iso.split('-'); return `${m}/${d}/${y}`; };
|
|
|
|
function buildChecksPreviewHTML(records, warnings) {
|
|
let html = `<div class="qbo-preview-count">${records.length} check${records.length !== 1 ? 's' : ''} found</div>`;
|
|
if (warnings && warnings.length) {
|
|
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
|
|
}
|
|
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
|
|
<thead><tr><th>Date</th><th>Payee</th><th style="text-align:right">Amount</th><th>Memo</th><th>Check #</th></tr></thead>
|
|
<tbody>`;
|
|
for (const r of records) {
|
|
html += `<tr>
|
|
<td>${escHtml(fmtDateDisp(r.date))}</td>
|
|
<td>${escHtml(r.payee || '')}</td>
|
|
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(r.amount))}</td>
|
|
<td class="text-muted">${escHtml(r.memo || '')}</td>
|
|
<td class="text-muted">${r.check_no ? escHtml(String(r.check_no)) : '<em>auto</em>'}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table></div>';
|
|
return html;
|
|
}
|
|
|
|
function buildDepositsPreviewHTML(records, warnings) {
|
|
const byDate = new Map();
|
|
for (const r of records) {
|
|
if (!byDate.has(r.date)) byDate.set(r.date, []);
|
|
byDate.get(r.date).push(r);
|
|
}
|
|
const dateCount = byDate.size;
|
|
let html = `<div class="qbo-preview-count">${records.length} item${records.length !== 1 ? 's' : ''} across ${dateCount} deposit${dateCount !== 1 ? 's' : ''}</div>`;
|
|
if (warnings && warnings.length) {
|
|
html += `<div class="qbo-warnings">${warnings.map(w => escHtml(w)).join('<br>')}</div>`;
|
|
}
|
|
html += `<div class="qbo-preview-scroll"><table class="qbo-preview-table">
|
|
<thead><tr><th>Date</th><th>Items</th><th style="text-align:right">Total</th></tr></thead>
|
|
<tbody>`;
|
|
for (const [date, items] of byDate) {
|
|
const total = items.reduce((s, i) => s + i.amount, 0);
|
|
html += `<tr>
|
|
<td>${escHtml(fmtDateDisp(date))}</td>
|
|
<td class="text-muted">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
|
<td style="text-align:right;font-family:monospace">${escHtml(fmtCurrency(total))}</td>
|
|
</tr>`;
|
|
}
|
|
html += '</tbody></table></div>';
|
|
return html;
|
|
}
|
|
|
|
async function qboConfirmImport(type) {
|
|
const records = type === 'checks' ? qboChecksRecords : qboDepositsRecords;
|
|
const errEl = document.getElementById(`qbo-${type}-error`);
|
|
const resultEl = document.getElementById(`qbo-${type}-result`);
|
|
const importBtn = document.getElementById(`btn-qbo-${type}-import`);
|
|
|
|
errEl.hidden = true;
|
|
importBtn.disabled = true;
|
|
importBtn.textContent = 'Importing\u2026';
|
|
|
|
try {
|
|
const data = await apiFetch('POST', '/api/qbo-import/confirm', {
|
|
type, records, account_id: state.activeAccountId,
|
|
});
|
|
|
|
if (type === 'checks') {
|
|
resultEl.textContent = `Imported ${data.imported} check${data.imported !== 1 ? 's' : ''}${data.skipped ? `, skipped ${data.skipped} duplicate${data.skipped !== 1 ? 's' : ''}` : ''}.`;
|
|
await loadChecks();
|
|
await loadAccounts();
|
|
renderHeader();
|
|
} else {
|
|
resultEl.textContent = `Imported ${data.imported} deposit${data.imported !== 1 ? 's' : ''} (${data.itemCount} items).`;
|
|
if (typeof loadDeposits === 'function') await loadDeposits();
|
|
}
|
|
resultEl.hidden = false;
|
|
importBtn.hidden = true;
|
|
|
|
document.getElementById(`qbo-${type}-file`).value = '';
|
|
if (type === 'checks') qboChecksRecords = null;
|
|
else qboDepositsRecords = null;
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
importBtn.disabled = false;
|
|
importBtn.textContent = type === 'checks' ? 'Import Checks' : 'Import Deposits';
|
|
}
|
|
}
|
|
|
|
// ── Set next check number ─────────────────────────────────────────────────────
|
|
|
|
function openSetCheckNo() {
|
|
const current = state.account ? state.account.current_check_no + 1 : 1;
|
|
document.getElementById('set-check-no-input').value = current;
|
|
document.getElementById('set-check-no-error').hidden = true;
|
|
document.getElementById('set-check-no-overlay').classList.add('open');
|
|
document.getElementById('set-check-no-modal').classList.add('open');
|
|
document.getElementById('set-check-no-input').focus();
|
|
document.getElementById('set-check-no-input').select();
|
|
}
|
|
|
|
function closeSetCheckNo() {
|
|
document.getElementById('set-check-no-overlay').classList.remove('open');
|
|
document.getElementById('set-check-no-modal').classList.remove('open');
|
|
}
|
|
|
|
async function saveSetCheckNo() {
|
|
const errEl = document.getElementById('set-check-no-error');
|
|
const input = document.getElementById('set-check-no-input');
|
|
const next = parseInt(input.value, 10);
|
|
if (isNaN(next) || next < 1) {
|
|
errEl.textContent = 'Enter a valid check number (1 or higher).';
|
|
errEl.hidden = false;
|
|
return;
|
|
}
|
|
const btn = document.getElementById('btn-confirm-set-check-no');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving…';
|
|
try {
|
|
await apiFetch('PUT', `/api/account/${state.activeAccountId}/check-no`, { next_check_no: next });
|
|
state.account.current_check_no = next - 1;
|
|
renderHeader();
|
|
closeSetCheckNo();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Set Number';
|
|
}
|
|
}
|
|
|
|
// ── Deposits ─────────────────────────────────────────────────────────────────
|
|
|
|
const depState = {
|
|
deposits: [],
|
|
editingId: null,
|
|
items: [], // working list of check rows in the panel
|
|
};
|
|
|
|
async function loadDeposits() {
|
|
if (!state.activeAccountId) return;
|
|
const tbody = document.getElementById('deposits-tbody');
|
|
tbody.innerHTML = '<tr class="loading-row"><td colspan="8">Loading…</td></tr>';
|
|
try {
|
|
depState.deposits = await apiFetch('GET', `/api/deposits?account_id=${state.activeAccountId}`);
|
|
renderDepositsTable();
|
|
} catch (err) {
|
|
tbody.innerHTML = `<tr class="empty-row"><td colspan="8">Error: ${escHtml(err.message)}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function renderDepositsTable() {
|
|
const tbody = document.getElementById('deposits-tbody');
|
|
const from = document.getElementById('dep-filter-from').value;
|
|
const to = document.getElementById('dep-filter-to').value;
|
|
const status = document.getElementById('dep-filter-status').value;
|
|
|
|
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n || 0);
|
|
const fmtDate = d => d ? new Date(d + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—';
|
|
|
|
let list = depState.deposits.filter(d => {
|
|
if (from && d.deposit_date < from) return false;
|
|
if (to && d.deposit_date > to) return false;
|
|
if (status === '0' && d.printed) return false;
|
|
if (status === '1' && !d.printed) return false;
|
|
return true;
|
|
});
|
|
|
|
if (list.length === 0) {
|
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">No deposits found.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = list.map(d => {
|
|
const cashTotal = (d.currency || 0) + (d.coin || 0);
|
|
const checksTotal = d.checks_total || 0;
|
|
const depositTotal = cashTotal + checksTotal - (d.cash_back || 0);
|
|
const printed = !!d.printed;
|
|
const badge = printed
|
|
? '<span class="status-badge status-printed">Printed</span>'
|
|
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
|
return `<tr class="${printed ? 'printed' : ''}">
|
|
<td class="col-date">${fmtDate(d.deposit_date)}</td>
|
|
<td class="col-amount" style="text-align:right">${fmt(checksTotal)}</td>
|
|
<td class="col-amount" style="text-align:right">${fmt(cashTotal)}</td>
|
|
<td class="col-amount" style="text-align:right">${fmt(d.cash_back)}</td>
|
|
<td class="col-amount" style="text-align:right"><strong>${fmt(depositTotal)}</strong></td>
|
|
<td style="text-align:center">${d.item_count || 0}</td>
|
|
<td class="col-status">${badge}</td>
|
|
<td class="col-actions">
|
|
<button class="btn-sm btn-edit dep-btn-edit" data-id="${d.id}">Edit</button>
|
|
<button class="btn-sm btn-delete dep-btn-delete" data-id="${d.id}">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
tbody.querySelectorAll('.dep-btn-edit').forEach(btn =>
|
|
btn.addEventListener('click', () => openDepositPanel(parseInt(btn.dataset.id, 10))));
|
|
tbody.querySelectorAll('.dep-btn-delete').forEach(btn =>
|
|
btn.addEventListener('click', () => deleteDeposit(parseInt(btn.dataset.id, 10))));
|
|
}
|
|
|
|
async function openDepositPanel(id = null) {
|
|
depState.editingId = id;
|
|
depState.items = [];
|
|
|
|
document.getElementById('dep-panel-error').hidden = true;
|
|
document.getElementById('dep-panel-title').textContent = id ? 'Edit Deposit' : 'New Deposit';
|
|
document.getElementById('dep-date').value = new Date().toISOString().slice(0, 10);
|
|
document.getElementById('dep-currency').value = '';
|
|
document.getElementById('dep-coin').value = '';
|
|
document.getElementById('dep-cashback').value = '';
|
|
|
|
if (id !== null) {
|
|
try {
|
|
const dep = await apiFetch('GET', `/api/deposits/${id}`);
|
|
document.getElementById('dep-date').value = dep.deposit_date || '';
|
|
document.getElementById('dep-currency').value = dep.currency || '';
|
|
document.getElementById('dep-coin').value = dep.coin || '';
|
|
document.getElementById('dep-cashback').value = dep.cash_back || '';
|
|
depState.items = (dep.items || []).map(it => ({ ...it }));
|
|
} catch (err) {
|
|
alert('Error loading deposit: ' + err.message);
|
|
return;
|
|
}
|
|
} else {
|
|
depState.items = [newDepItem()];
|
|
}
|
|
|
|
renderDepItems();
|
|
recalcDepTotals();
|
|
|
|
const slipBtn = document.getElementById('btn-dep-slip');
|
|
const reportBtn = document.getElementById('btn-dep-report');
|
|
slipBtn.disabled = id === null;
|
|
reportBtn.disabled = id === null;
|
|
|
|
document.getElementById('dep-panel-overlay').classList.add('open');
|
|
document.getElementById('deposit-panel').classList.add('open');
|
|
document.getElementById('dep-date').focus();
|
|
}
|
|
|
|
function closeDepositPanel() {
|
|
document.getElementById('dep-panel-overlay').classList.remove('open');
|
|
document.getElementById('deposit-panel').classList.remove('open');
|
|
depState.editingId = null;
|
|
depState.items = [];
|
|
}
|
|
|
|
function newDepItem() {
|
|
return { _key: Math.random(), check_no: '', bank_no: '', payee: '', memo: '', amount: '' };
|
|
}
|
|
|
|
function renderDepItems() {
|
|
const tbody = document.getElementById('dep-items-tbody');
|
|
tbody.innerHTML = depState.items.map((item, i) => `
|
|
<tr data-idx="${i}">
|
|
<td><input class="dep-item-input" data-field="check_no" value="${escHtml(item.check_no || '')}" placeholder="Check #" style="width:70px"></td>
|
|
<td><input class="dep-item-input" data-field="payee" value="${escHtml(item.payee || '')}" placeholder="Payee" style="width:110px"></td>
|
|
<td><input class="dep-item-input" data-field="memo" value="${escHtml(item.memo || '')}" placeholder="Memo" style="width:90px"></td>
|
|
<td><input class="dep-item-input dep-amount-input" data-field="amount" value="${item.amount !== '' ? item.amount : ''}" placeholder="0.00" style="width:80px;text-align:right" type="number" min="0" step="0.01"></td>
|
|
<td><button class="btn-sm btn-delete dep-item-remove" data-idx="${i}" tabindex="-1">✕</button></td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
tbody.querySelectorAll('.dep-item-input').forEach(inp => {
|
|
inp.addEventListener('input', e => {
|
|
const row = e.target.closest('tr');
|
|
const idx = parseInt(row.dataset.idx, 10);
|
|
depState.items[idx][e.target.dataset.field] = e.target.value;
|
|
if (e.target.dataset.field === 'amount') recalcDepTotals();
|
|
});
|
|
});
|
|
tbody.querySelectorAll('.dep-item-remove').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
depState.items.splice(parseInt(btn.dataset.idx, 10), 1);
|
|
renderDepItems();
|
|
recalcDepTotals();
|
|
});
|
|
});
|
|
}
|
|
|
|
function recalcDepTotals() {
|
|
const fmt = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
|
|
const currency = parseFloat(document.getElementById('dep-currency').value) || 0;
|
|
const coin = parseFloat(document.getElementById('dep-coin').value) || 0;
|
|
const cashBack = parseFloat(document.getElementById('dep-cashback').value) || 0;
|
|
const cashTotal = currency + coin;
|
|
const checksTotal = depState.items.reduce((s, it) => s + (parseFloat(it.amount) || 0), 0);
|
|
const subTotal = cashTotal + checksTotal;
|
|
const grand = subTotal - cashBack;
|
|
|
|
document.getElementById('dep-cash-total').textContent = fmt(cashTotal);
|
|
document.getElementById('dep-checks-total').textContent = fmt(checksTotal);
|
|
document.getElementById('dep-subtotal').textContent = fmt(subTotal);
|
|
document.getElementById('dep-cashback-display').textContent = fmt(cashBack);
|
|
document.getElementById('dep-grand-total').textContent = fmt(grand);
|
|
}
|
|
|
|
async function saveDeposit() {
|
|
const errEl = document.getElementById('dep-panel-error');
|
|
errEl.hidden = true;
|
|
|
|
const deposit_date = document.getElementById('dep-date').value;
|
|
if (!deposit_date) {
|
|
errEl.textContent = 'Deposit date is required.';
|
|
errEl.hidden = false;
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
account_id: state.activeAccountId,
|
|
deposit_date,
|
|
currency: parseFloat(document.getElementById('dep-currency').value) || 0,
|
|
coin: parseFloat(document.getElementById('dep-coin').value) || 0,
|
|
cash_back: parseFloat(document.getElementById('dep-cashback').value) || 0,
|
|
items: depState.items
|
|
.filter(it => parseFloat(it.amount) > 0 || it.check_no || it.payee)
|
|
.map((it, i) => ({
|
|
sort_order: i,
|
|
check_no: it.check_no || null,
|
|
bank_no: it.bank_no || null,
|
|
payee: it.payee || null,
|
|
memo: it.memo || null,
|
|
amount: parseFloat(it.amount) || 0,
|
|
})),
|
|
};
|
|
|
|
const btn = document.getElementById('btn-save-deposit');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Saving…';
|
|
|
|
try {
|
|
let saved;
|
|
if (depState.editingId !== null) {
|
|
saved = await apiFetch('PUT', `/api/deposits/${depState.editingId}`, payload);
|
|
} else {
|
|
saved = await apiFetch('POST', '/api/deposits', payload);
|
|
}
|
|
depState.editingId = saved.id;
|
|
// Enable PDF buttons now that deposit is saved
|
|
document.getElementById('btn-dep-slip').disabled = false;
|
|
document.getElementById('btn-dep-report').disabled = false;
|
|
document.getElementById('dep-panel-title').textContent = 'Edit Deposit';
|
|
await loadDeposits();
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save Deposit';
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.hidden = false;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Save Deposit';
|
|
}
|
|
}
|
|
|
|
async function deleteDeposit(id) {
|
|
const dep = depState.deposits.find(d => d.id === id);
|
|
const label = dep ? dep.deposit_date : `#${id}`;
|
|
if (!confirm(`Delete deposit from ${label}?`)) return;
|
|
try {
|
|
await apiFetch('DELETE', `/api/deposits/${id}`);
|
|
await loadDeposits();
|
|
} catch (err) {
|
|
alert('Error: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function generateDepositPdf(type) {
|
|
if (!depState.editingId) return;
|
|
const btn = type === 'slip'
|
|
? document.getElementById('btn-dep-slip')
|
|
: document.getElementById('btn-dep-report');
|
|
btn.disabled = true;
|
|
const orig = btn.textContent;
|
|
btn.textContent = '…';
|
|
try {
|
|
const res = await fetch('/api/deposit-pdf', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ depositId: depState.editingId, type, mark_printed: type === 'slip' }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || res.statusText);
|
|
}
|
|
const blob = await res.blob();
|
|
window.open(URL.createObjectURL(blob), '_blank');
|
|
if (type === 'slip') await loadDeposits();
|
|
} catch (err) {
|
|
alert('PDF error: ' + err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
|
|
function escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ── Initialization ───────────────────────────────────────────────────────────
|
|
|
|
async function init() {
|
|
// Column sort
|
|
document.querySelectorAll('thead th.sortable').forEach(th => {
|
|
th.addEventListener('click', () => {
|
|
if (state.sortCol === th.dataset.col) {
|
|
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
state.sortCol = th.dataset.col;
|
|
state.sortDir = th.dataset.col === 'check_no' ? 'desc' : 'asc';
|
|
}
|
|
renderTable();
|
|
});
|
|
});
|
|
|
|
// Filters (client-side; just re-render)
|
|
document.getElementById('filter-payee').addEventListener('input', e => {
|
|
state.filterPayee = e.target.value;
|
|
renderTable();
|
|
});
|
|
document.getElementById('filter-date-from').addEventListener('change', e => {
|
|
state.filterDateFrom = e.target.value;
|
|
renderTable();
|
|
});
|
|
document.getElementById('filter-date-to').addEventListener('change', e => {
|
|
state.filterDateTo = e.target.value;
|
|
renderTable();
|
|
});
|
|
document.getElementById('filter-status').addEventListener('change', e => {
|
|
state.filterStatus = e.target.value;
|
|
renderTable();
|
|
});
|
|
|
|
// Select-all checkbox
|
|
document.getElementById('select-all-checks').addEventListener('change', e => {
|
|
const checks = filteredAndSortedChecks();
|
|
if (e.target.checked) {
|
|
checks.forEach(c => state.selected.add(c.id));
|
|
} else {
|
|
checks.forEach(c => state.selected.delete(c.id));
|
|
}
|
|
renderTable();
|
|
refreshPdfButton();
|
|
});
|
|
|
|
// New check
|
|
document.getElementById('btn-new-check').addEventListener('click', () => openPanel());
|
|
|
|
// Panel close
|
|
document.getElementById('btn-close-panel').addEventListener('click', closePanel);
|
|
document.getElementById('btn-cancel').addEventListener('click', closePanel);
|
|
document.getElementById('panel-overlay').addEventListener('click', closePanel);
|
|
|
|
// Form submit
|
|
document.getElementById('check-form').addEventListener('submit', saveCheck);
|
|
|
|
// Generate PDF
|
|
document.getElementById('btn-generate-pdf').addEventListener('click', generatePdf);
|
|
|
|
// Wizard
|
|
document.getElementById('btn-wizard-next').addEventListener('click', () => {
|
|
if (validateWizardStep()) goToWizardStep(wizard.step + 1);
|
|
});
|
|
document.getElementById('btn-wizard-prev').addEventListener('click', () => goToWizardStep(wizard.step - 1));
|
|
document.getElementById('btn-wizard-finish').addEventListener('click', finishWizard);
|
|
document.getElementById('btn-wizard-skip').addEventListener('click', () => {
|
|
closeWizard();
|
|
openImportModal();
|
|
});
|
|
document.getElementById('w-logo').addEventListener('change', e => {
|
|
const file = e.target.files[0];
|
|
if (!file) { wizard.logoData = null; return; }
|
|
const reader = new FileReader();
|
|
reader.onload = ev => { wizard.logoData = ev.target.result; };
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// Import modal
|
|
document.getElementById('btn-import').addEventListener('click', openImportModal);
|
|
document.getElementById('btn-close-import').addEventListener('click', closeImportModal);
|
|
document.getElementById('btn-cancel-import').addEventListener('click', closeImportModal);
|
|
document.getElementById('import-modal-overlay').addEventListener('click', closeImportModal);
|
|
document.getElementById('btn-run-import').addEventListener('click', runImport);
|
|
|
|
// Account switcher
|
|
document.getElementById('account-switcher').addEventListener('change', e => {
|
|
switchAccount(parseInt(e.target.value, 10));
|
|
});
|
|
|
|
// Account settings modal
|
|
document.getElementById('btn-account-settings').addEventListener('click', openAccountSettings);
|
|
document.getElementById('btn-close-acct-settings').addEventListener('click', closeAccountSettings);
|
|
document.getElementById('btn-cancel-acct-settings').addEventListener('click', closeAccountSettings);
|
|
document.getElementById('acct-settings-overlay').addEventListener('click', closeAccountSettings);
|
|
document.getElementById('btn-save-acct-settings').addEventListener('click', saveAccountSettings);
|
|
|
|
document.getElementById('btn-delete-account').addEventListener('click', openDeleteAccount);
|
|
document.getElementById('btn-close-delete-account').addEventListener('click', closeDeleteAccount);
|
|
document.getElementById('btn-cancel-delete-account').addEventListener('click', closeDeleteAccount);
|
|
document.getElementById('delete-account-overlay').addEventListener('click', closeDeleteAccount);
|
|
document.getElementById('btn-confirm-delete-account').addEventListener('click', confirmDeleteAccount);
|
|
|
|
document.getElementById('btn-set-check-no').addEventListener('click', openSetCheckNo);
|
|
document.getElementById('btn-close-set-check-no').addEventListener('click', closeSetCheckNo);
|
|
document.getElementById('btn-cancel-set-check-no').addEventListener('click', closeSetCheckNo);
|
|
document.getElementById('set-check-no-overlay').addEventListener('click', closeSetCheckNo);
|
|
document.getElementById('btn-confirm-set-check-no').addEventListener('click', saveSetCheckNo);
|
|
document.getElementById('set-check-no-input').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') saveSetCheckNo();
|
|
if (e.key === 'Escape') closeSetCheckNo();
|
|
});
|
|
document.getElementById('as-logo').addEventListener('change', e => {
|
|
const file = e.target.files[0];
|
|
if (!file) { acctSettings.logoData = null; return; }
|
|
const reader = new FileReader();
|
|
reader.onload = ev => {
|
|
acctSettings.logoData = ev.target.result;
|
|
const preview = document.getElementById('as-logo-preview');
|
|
preview.innerHTML = `<img src="${ev.target.result}" alt="Logo preview">`;
|
|
preview.hidden = false;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// View tabs (Checks / Deposits)
|
|
document.querySelectorAll('.view-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const view = tab.dataset.view;
|
|
document.getElementById('view-checks').hidden = view !== 'checks';
|
|
document.getElementById('view-deposits').hidden = view !== 'deposits';
|
|
if (view === 'deposits') loadDeposits();
|
|
});
|
|
});
|
|
|
|
// Deposit filters
|
|
document.getElementById('dep-filter-from').addEventListener('change', renderDepositsTable);
|
|
document.getElementById('dep-filter-to').addEventListener('change', renderDepositsTable);
|
|
document.getElementById('dep-filter-status').addEventListener('change', renderDepositsTable);
|
|
|
|
// Deposit panel
|
|
document.getElementById('btn-new-deposit').addEventListener('click', () => openDepositPanel());
|
|
document.getElementById('btn-close-dep-panel').addEventListener('click', closeDepositPanel);
|
|
document.getElementById('btn-cancel-deposit').addEventListener('click', closeDepositPanel);
|
|
document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel);
|
|
document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit);
|
|
document.getElementById('btn-add-dep-item').addEventListener('click', () => {
|
|
depState.items.push(newDepItem());
|
|
renderDepItems();
|
|
});
|
|
document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip'));
|
|
document.getElementById('btn-dep-report').addEventListener('click', () => generateDepositPdf('report'));
|
|
|
|
// Deposit panel live recalc
|
|
['dep-currency', 'dep-coin', 'dep-cashback'].forEach(id => {
|
|
document.getElementById(id).addEventListener('input', recalcDepTotals);
|
|
});
|
|
|
|
// QBO Import
|
|
document.querySelectorAll('[data-open-qbo]').forEach(btn =>
|
|
btn.addEventListener('click', () => openQboImport(btn.dataset.openQbo))
|
|
);
|
|
document.getElementById('btn-close-qbo-import').addEventListener('click', closeQboImport);
|
|
document.getElementById('qbo-import-overlay').addEventListener('click', closeQboImport);
|
|
document.querySelectorAll('.qbo-tab').forEach(t =>
|
|
t.addEventListener('click', () => switchQboTab(t.dataset.tab))
|
|
);
|
|
document.getElementById('btn-qbo-checks-parse').addEventListener('click', () => qboParseFile('checks'));
|
|
document.getElementById('btn-qbo-deposits-parse').addEventListener('click', () => qboParseFile('deposits'));
|
|
document.getElementById('btn-qbo-checks-import').addEventListener('click', () => qboConfirmImport('checks'));
|
|
document.getElementById('btn-qbo-deposits-import').addEventListener('click', () => qboConfirmImport('deposits'));
|
|
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
|
|
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
|
|
|
|
// Auth event listeners
|
|
document.getElementById('btn-login-submit').addEventListener('click', submitLogin);
|
|
document.getElementById('btn-setup-submit').addEventListener('click', submitSetup);
|
|
document.getElementById('btn-logout').addEventListener('click', logout);
|
|
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') submitLogin(); });
|
|
document.getElementById('setup-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitSetup(); });
|
|
|
|
// User management
|
|
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
|
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
|
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
|
document.getElementById('btn-save-user').addEventListener('click', saveUser);
|
|
document.getElementById('btn-cancel-user-edit').addEventListener('click', cancelUserEdit);
|
|
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
|
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
|
|
|
// Initial auth check → loads app if already signed in
|
|
const authed = await checkAuth();
|
|
if (authed) await loadAccounts();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|