Files
check-printing/public/js/app.js
T

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 isEditor = state.user && (state.user.role === 'admin' || state.user.role === 'editor');
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 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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);