Add first-run setup wizard with 3-step account configuration
This commit is contained in:
@@ -411,3 +411,77 @@ input[type="file"] {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Setup wizard ── */
|
||||||
|
.wizard-modal { width: 520px; }
|
||||||
|
|
||||||
|
.wizard-steps-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.wizard-step-dot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wizard-step-dot span {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.wizard-step-dot.active span,
|
||||||
|
.wizard-step-dot.done span {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.wizard-step-dot label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wizard-step-dot.active label { color: var(--primary); }
|
||||||
|
.wizard-step-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.wizard-step-line.done { background: var(--primary); }
|
||||||
|
|
||||||
|
.wizard-body { min-height: 240px; }
|
||||||
|
|
||||||
|
.wizard-error {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-footer { justify-content: flex-end; }
|
||||||
|
.wizard-footer .btn-ghost { margin-right: auto; font-size: 12px; }
|
||||||
|
|
||||||
|
.form-row-3 { grid-template-columns: 1fr 80px 100px; }
|
||||||
|
.field-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,103 @@
|
|||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Setup wizard -->
|
||||||
|
<div id="wizard-overlay" class="modal-overlay"></div>
|
||||||
|
<div id="wizard-modal" class="modal wizard-modal" role="dialog" aria-labelledby="wizard-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="wizard-title">Account Setup</h2>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-steps-indicator">
|
||||||
|
<div class="wizard-step-dot active" data-step="1"><span>1</span><label>Checkwriter</label></div>
|
||||||
|
<div class="wizard-step-line"></div>
|
||||||
|
<div class="wizard-step-dot" data-step="2"><span>2</span><label>Bank</label></div>
|
||||||
|
<div class="wizard-step-line"></div>
|
||||||
|
<div class="wizard-step-dot" data-step="3"><span>3</span><label>Account</label></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body wizard-body">
|
||||||
|
<!-- Step 1: Checkwriter -->
|
||||||
|
<div class="wizard-step" id="wizard-step-1">
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="w-company1">Organization Name</label>
|
||||||
|
<input type="text" id="w-company1" autocomplete="organization">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-addr1">Address</label>
|
||||||
|
<input type="text" id="w-addr1" autocomplete="street-address">
|
||||||
|
</div>
|
||||||
|
<div class="form-row form-row-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-city">City</label>
|
||||||
|
<input type="text" id="w-city" autocomplete="address-level2">
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-sm">
|
||||||
|
<label for="w-state">State</label>
|
||||||
|
<input type="text" id="w-state" maxlength="2" autocomplete="address-level1" placeholder="MT">
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group-sm">
|
||||||
|
<label for="w-zip">ZIP</label>
|
||||||
|
<input type="text" id="w-zip" maxlength="10" autocomplete="postal-code">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-contact">Phone / Website / Email</label>
|
||||||
|
<input type="text" id="w-contact" placeholder="e.g. 406-555-0100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Bank -->
|
||||||
|
<div class="wizard-step" id="wizard-step-2" hidden>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="w-bank-name">Bank Name</label>
|
||||||
|
<input type="text" id="w-bank-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-bank-addr">Bank Address</label>
|
||||||
|
<input type="text" id="w-bank-addr">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-transit">Transit Code</label>
|
||||||
|
<input type="text" id="w-transit" placeholder="e.g. 092900383">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-bank-contact">Phone / Website</label>
|
||||||
|
<input type="text" id="w-bank-contact">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Account -->
|
||||||
|
<div class="wizard-step" id="wizard-step-3" hidden>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="w-routing">Routing Number</label>
|
||||||
|
<input type="text" id="w-routing" inputmode="numeric" maxlength="9">
|
||||||
|
</div>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="w-account">Account Number</label>
|
||||||
|
<input type="text" id="w-account" inputmode="numeric">
|
||||||
|
</div>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="w-start-check">Starting Check Number</label>
|
||||||
|
<input type="number" id="w-start-check" min="1" step="1" value="1001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="w-logo">Logo (optional)</label>
|
||||||
|
<input type="file" id="w-logo" accept="image/*">
|
||||||
|
<span class="field-hint">Printed top-left of each check. PNG or GIF recommended.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wizard-error" class="wizard-error" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer wizard-footer">
|
||||||
|
<button id="btn-wizard-prev" class="btn-secondary" hidden>← Back</button>
|
||||||
|
<button id="btn-wizard-next" class="btn-primary">Next →</button>
|
||||||
|
<button id="btn-wizard-finish" class="btn-primary" hidden>Save & Start</button>
|
||||||
|
<button id="btn-wizard-skip" class="btn-ghost">Use Import instead</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Import modal -->
|
<!-- Import modal -->
|
||||||
<div id="import-modal-overlay" class="modal-overlay"></div>
|
<div id="import-modal-overlay" class="modal-overlay"></div>
|
||||||
<div id="import-modal" class="modal" role="dialog" aria-labelledby="import-modal-title">
|
<div id="import-modal" class="modal" role="dialog" aria-labelledby="import-modal-title">
|
||||||
|
|||||||
+126
-2
@@ -28,8 +28,8 @@ async function loadAccount() {
|
|||||||
try {
|
try {
|
||||||
state.account = await apiFetch('GET', '/api/account');
|
state.account = await apiFetch('GET', '/api/account');
|
||||||
renderHeader();
|
renderHeader();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// account not configured yet — silently skip
|
if (err.message && err.message.includes('No account')) openWizard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +337,112 @@ async function reprintCheck(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 {
|
||||||
|
await apiFetch('POST', '/api/account/setup', payload);
|
||||||
|
closeWizard();
|
||||||
|
await Promise.all([loadAccount(), loadChecks()]);
|
||||||
|
} catch (err) {
|
||||||
|
const errEl = document.getElementById('wizard-error');
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.hidden = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save & Start';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Import modal ─────────────────────────────────────────────────────────────
|
// ── Import modal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function openImportModal() {
|
function openImportModal() {
|
||||||
@@ -442,6 +548,24 @@ function init() {
|
|||||||
// Generate PDF
|
// Generate PDF
|
||||||
document.getElementById('btn-generate-pdf').addEventListener('click', generatePdf);
|
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
|
// Import modal
|
||||||
document.getElementById('btn-import').addEventListener('click', openImportModal);
|
document.getElementById('btn-import').addEventListener('click', openImportModal);
|
||||||
document.getElementById('btn-close-import').addEventListener('click', closeImportModal);
|
document.getElementById('btn-close-import').addEventListener('click', closeImportModal);
|
||||||
|
|||||||
+49
@@ -38,6 +38,55 @@ app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Account setup endpoint (first-run wizard)
|
||||||
|
app.post('/api/account/setup', (req, res) => {
|
||||||
|
const db = require('./db/database');
|
||||||
|
const existing = db.prepare('SELECT id FROM account WHERE id = 1').get();
|
||||||
|
if (existing) return res.status(409).json({ error: 'Account already configured.' });
|
||||||
|
|
||||||
|
const {
|
||||||
|
company1, company2, company3, company4,
|
||||||
|
bank_name, bank_info1, bank_info2, transit_code,
|
||||||
|
routing_number, account_number, start_check_no, logo_data,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!company1 || !routing_number || !account_number || !start_check_no) {
|
||||||
|
return res.status(400).json({ error: 'Organization name, routing number, account number, and starting check number are required.' });
|
||||||
|
}
|
||||||
|
const checkNo = parseInt(start_check_no, 10);
|
||||||
|
if (isNaN(checkNo) || checkNo < 1) {
|
||||||
|
return res.status(400).json({ error: 'Starting check number must be a positive integer.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO account (
|
||||||
|
bank_name, bank_info1, bank_info2, transit_code,
|
||||||
|
routing_number, account_number, start_check_no, current_check_no,
|
||||||
|
company1, company2, company3, company4, logo_data
|
||||||
|
) VALUES (
|
||||||
|
@bank_name, @bank_info1, @bank_info2, @transit_code,
|
||||||
|
@routing_number, @account_number, @start_check_no, @current_check_no,
|
||||||
|
@company1, @company2, @company3, @company4, @logo_data
|
||||||
|
)
|
||||||
|
`).run({
|
||||||
|
bank_name: bank_name || '',
|
||||||
|
bank_info1: bank_info1 || null,
|
||||||
|
bank_info2: bank_info2 || null,
|
||||||
|
transit_code: transit_code || null,
|
||||||
|
routing_number,
|
||||||
|
account_number,
|
||||||
|
start_check_no: checkNo,
|
||||||
|
current_check_no: checkNo,
|
||||||
|
company1: company1 || null,
|
||||||
|
company2: company2 || null,
|
||||||
|
company3: company3 || null,
|
||||||
|
company4: company4 || null,
|
||||||
|
logo_data: logo_data || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Account info endpoint (read-only for Phase 1)
|
// Account info endpoint (read-only for Phase 1)
|
||||||
app.get('/api/account', (req, res) => {
|
app.get('/api/account', (req, res) => {
|
||||||
const db = require('./db/database');
|
const db = require('./db/database');
|
||||||
|
|||||||
Reference in New Issue
Block a user