623 lines
25 KiB
HTML
623 lines
25 KiB
HTML
{% extends 'base.html' %}
|
|
{% block title %}Import Bank Statement{% endblock %}
|
|
{% block page_title %}Import Bank Statement{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
/* ── Drop zone ── */
|
|
#drop-zone {
|
|
border: 2px dashed var(--border); border-radius: 12px;
|
|
padding: 40px 20px; text-align: center; cursor: pointer;
|
|
transition: all .2s; background: #f8fafc; position: relative;
|
|
}
|
|
#drop-zone.drag-over { border-color: var(--accent); background: #eff6ff; }
|
|
#drop-zone.has-file { border-color: #10b981; background: #f0fdf4; }
|
|
#file-name { font-size: 13px; color: var(--muted); margin-top: 8px; }
|
|
|
|
/* ── Steps ── */
|
|
.step { display: none; }
|
|
.step.active { display: block; }
|
|
|
|
/* ── Preview table ── */
|
|
#preview-table { font-size: 12.5px; }
|
|
#preview-table th {
|
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: .06em; color: var(--muted); padding: 9px 12px;
|
|
background: #f8fafc; border-bottom: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
}
|
|
#preview-table td { padding: 7px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
#preview-table tbody tr:hover { background: #f8fafc; }
|
|
#preview-table tbody tr.row-unchecked { opacity: .45; }
|
|
#preview-table .cat-select { font-size: 12px; min-width: 150px; }
|
|
.mono-amt { font-family: 'DM Mono', monospace; font-size: 12px; white-space: nowrap; }
|
|
|
|
/* ── Format badge ── */
|
|
.fmt-badge {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: #dbeafe; color: #1e40af; font-size: 12px;
|
|
font-weight: 600; padding: 3px 10px; border-radius: 20px;
|
|
}
|
|
|
|
/* ── Column mapping ── */
|
|
.map-row { display: grid; grid-template-columns: 160px 1fr; align-items: center; gap: 12px; margin-bottom: 12px; }
|
|
.map-row label { font-size: 13px; font-weight: 500; }
|
|
|
|
/* ── Supported formats list ── */
|
|
.fmt-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
.fmt-chip {
|
|
font-size: 11px; padding: 3px 10px; border-radius: 20px;
|
|
background: #f1f5f9; color: #475569; font-weight: 500;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row g-4">
|
|
|
|
{# ── LEFT COLUMN: upload form + supported formats ── #}
|
|
<div class="col-12 col-lg-4">
|
|
<div class="pcard">
|
|
<div class="pcard-title mb-3">Upload Statement</div>
|
|
|
|
{# File input lives outside the drop zone so it never overlaps other controls #}
|
|
<input type="file" id="file-input" accept=".csv,.ofx,.qfx,.txt,.pdf" style="display:none;">
|
|
|
|
{# Drop zone — clicking it programmatically triggers the hidden file input #}
|
|
<div id="drop-zone" class="mb-3">
|
|
<i class="bi bi-cloud-upload fs-2 text-muted d-block mb-2"></i>
|
|
<div class="fw-medium" style="font-size:13px;">Drag & drop or click to browse</div>
|
|
<div class="text-muted" style="font-size:12px; margin-top:4px;">CSV, OFX, QFX, PDF</div>
|
|
</div>
|
|
<div id="file-name" class="mb-3 d-none"><i class="bi bi-file-earmark-text me-1"></i><span id="fn-text"></span></div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-medium" style="font-size:13px;">Account</label>
|
|
<select id="account-select" class="form-select form-select-sm">
|
|
<option value="">— Select account —</option>
|
|
{% for a in accounts %}
|
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<small class="text-muted" style="font-size:11px;">All transactions will be linked to this account.</small>
|
|
</div>
|
|
|
|
<div class="form-check mb-4">
|
|
<input class="form-check-input" type="checkbox" id="skip-dupes" checked>
|
|
<label class="form-check-label" for="skip-dupes" style="font-size:13px;">Skip duplicate transactions</label>
|
|
</div>
|
|
|
|
<button id="parse-btn" class="btn btn-primary w-100" disabled>
|
|
<span id="parse-spinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
|
|
<i class="bi bi-search me-1" id="parse-icon"></i>Parse Statement
|
|
</button>
|
|
|
|
<div id="parse-error" class="alert alert-danger mt-3 d-none" style="font-size:12px;"></div>
|
|
</div>
|
|
|
|
{# Supported formats #}
|
|
<div class="pcard mt-3">
|
|
<div class="pcard-title mb-2">Supported Formats</div>
|
|
<div class="fmt-list mb-3">
|
|
<span class="fmt-chip" style="background:#fce7f3;color:#9d174d;">PDF <i class="bi bi-stars" style="font-size:10px;"></i></span>
|
|
<span class="fmt-chip">OFX / QFX</span>
|
|
<span class="fmt-chip">Chase</span>
|
|
<span class="fmt-chip">Bank of America</span>
|
|
<span class="fmt-chip">Citi</span>
|
|
<span class="fmt-chip">Capital One</span>
|
|
<span class="fmt-chip">Discover</span>
|
|
<span class="fmt-chip">USAA</span>
|
|
<span class="fmt-chip">Wells Fargo</span>
|
|
<span class="fmt-chip">Amex</span>
|
|
<span class="fmt-chip">Generic CSV</span>
|
|
</div>
|
|
<div class="text-muted" style="font-size:12px; line-height:1.7;">
|
|
<div class="d-flex gap-2 mb-2 p-2" style="background:#fdf4ff;border-radius:8px;border:1px solid #f0abfc;">
|
|
<i class="bi bi-stars text-purple" style="color:#9333ea;flex-shrink:0;margin-top:1px;"></i>
|
|
<div><strong style="color:#7e22ce;">PDF</strong> — Text is extracted from digital statements and parsed by the AI (Groq).
|
|
Works with any bank's PDF. Scanned/image PDFs are not supported.</div>
|
|
</div>
|
|
<strong>OFX/QFX</strong> is the most reliable structured format — download it from your bank's website.<br>
|
|
For CSV, the file must have at least a <strong>date</strong>, <strong>description</strong>, and
|
|
<strong>amount</strong> (or separate <strong>debit/credit</strong>) column.
|
|
If the format isn't recognised, you'll be asked to map columns manually.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ── RIGHT COLUMN: results ── #}
|
|
<div class="col-12 col-lg-8">
|
|
|
|
{# Step: column mapping #}
|
|
<div id="step-mapping" class="step pcard">
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<i class="bi bi-columns-gap fs-5 text-warning"></i>
|
|
<span class="fw-semibold">Column Mapping Required</span>
|
|
</div>
|
|
<p class="text-muted" style="font-size:13px;">
|
|
The CSV format wasn't recognised automatically. Please select which column contains each field.
|
|
</p>
|
|
<div id="mapping-form">
|
|
<div class="map-row">
|
|
<label>Date <span class="text-danger">*</span></label>
|
|
<select id="map-date" class="form-select form-select-sm"><option value="">— select column —</option></select>
|
|
</div>
|
|
<div class="map-row">
|
|
<label>Description <span class="text-danger">*</span></label>
|
|
<select id="map-desc" class="form-select form-select-sm"><option value="">— select column —</option></select>
|
|
</div>
|
|
<div class="map-row">
|
|
<label>Amount</label>
|
|
<select id="map-amount" class="form-select form-select-sm"><option value="">— select column —</option></select>
|
|
</div>
|
|
<div class="map-row">
|
|
<label>Debit (expense)</label>
|
|
<select id="map-debit" class="form-select form-select-sm"><option value="">— select column —</option></select>
|
|
</div>
|
|
<div class="map-row">
|
|
<label>Credit (income)</label>
|
|
<select id="map-credit" class="form-select form-select-sm"><option value="">— select column —</option></select>
|
|
</div>
|
|
<div class="map-row">
|
|
<label>Amount sign</label>
|
|
<select id="map-sign" class="form-select form-select-sm">
|
|
<option value="0">Negative = expense (most banks)</option>
|
|
<option value="1">Positive = expense (Discover)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div id="mapping-error" class="alert alert-danger mt-2 d-none" style="font-size:12px;"></div>
|
|
<button id="apply-mapping-btn" class="btn btn-primary mt-3">
|
|
<i class="bi bi-arrow-right me-1"></i>Apply Mapping & Preview
|
|
</button>
|
|
</div>
|
|
|
|
{# Step: preview #}
|
|
<div id="step-preview" class="step">
|
|
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
|
<span class="fmt-badge"><i class="bi bi-check-circle"></i><span id="fmt-label"></span></span>
|
|
<span class="text-muted" style="font-size:13px;" id="row-count-label"></span>
|
|
<div class="ms-auto d-flex gap-2 align-items-center">
|
|
<button id="select-all-btn" class="btn btn-sm btn-outline-secondary">Select All</button>
|
|
<button id="select-none-btn" class="btn btn-sm btn-outline-secondary">None</button>
|
|
<button id="import-btn" class="btn btn-sm btn-success px-3">
|
|
<span id="import-spinner" class="spinner-border spinner-border-sm me-1 d-none"></span>
|
|
<i class="bi bi-cloud-upload me-1" id="import-icon"></i>
|
|
Import <span id="import-count">0</span> transactions
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Parse errors (non-fatal) #}
|
|
<div id="parse-warnings" class="alert alert-warning d-none mb-3" style="font-size:12px;">
|
|
<strong>Warnings</strong> — these rows were skipped:<br>
|
|
<div id="parse-warnings-list"></div>
|
|
</div>
|
|
|
|
<div class="pcard p-0" style="overflow:hidden;">
|
|
<div style="overflow-x:auto;">
|
|
<table id="preview-table" class="w-100" style="border-collapse:collapse;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:36px; padding-left:16px;"><input type="checkbox" id="header-checkbox" checked></th>
|
|
<th>Date</th>
|
|
<th>Description</th>
|
|
<th style="width:80px;">Type</th>
|
|
<th style="width:110px;">Amount</th>
|
|
<th>Category</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="preview-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="import-error" class="alert alert-danger mt-3 d-none" style="font-size:12px;"></div>
|
|
</div>
|
|
|
|
{# Step: done #}
|
|
<div id="step-done" class="step pcard text-center py-5">
|
|
<i class="bi bi-check-circle-fill text-success fs-1 d-block mb-3"></i>
|
|
<h5 class="fw-semibold mb-1" id="done-title"></h5>
|
|
<p class="text-muted mb-4" id="done-subtitle"></p>
|
|
<div class="d-flex justify-content-center gap-3">
|
|
<a href="{{ url_for('transactions.index') }}" class="btn btn-primary">
|
|
<i class="bi bi-arrow-right me-1"></i>View Transactions
|
|
</a>
|
|
<button id="import-another-btn" class="btn btn-outline-secondary">Import Another File</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Placeholder when nothing parsed yet #}
|
|
<div id="step-empty" class="step pcard text-center py-5 text-muted">
|
|
<i class="bi bi-bank fs-2 d-block mb-3 opacity-25"></i>
|
|
<div style="font-size:13px;">Upload a bank statement to get started.</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
// ── Category data for dropdowns ──────────────────────────────────────────
|
|
const CATEGORIES = [
|
|
{% for c in categories %}
|
|
{ id: {{ c.id }}, name: {{ c.name | tojson }}, type: {{ c.category_type | tojson }} },
|
|
{% endfor %}
|
|
];
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────
|
|
let parsedRows = []; // enriched rows from /parse
|
|
let selectedFile = null; // File object
|
|
|
|
// ── Element shortcuts ────────────────────────────────────────────────────
|
|
const $ = id => document.getElementById(id);
|
|
|
|
function showStep(name) {
|
|
['step-mapping','step-preview','step-done','step-empty'].forEach(id => {
|
|
$(id).classList.remove('active');
|
|
});
|
|
$(name).classList.add('active');
|
|
}
|
|
|
|
// ── Drop zone ────────────────────────────────────────────────────────────
|
|
const dropZone = $('drop-zone');
|
|
const fileInput = $('file-input');
|
|
|
|
dropZone.addEventListener('click', () => fileInput.click());
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
|
dropZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('drag-over');
|
|
const f = e.dataTransfer.files[0];
|
|
if (f) setFile(f);
|
|
});
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files[0]) setFile(fileInput.files[0]);
|
|
});
|
|
|
|
function setFile(f) {
|
|
selectedFile = f;
|
|
dropZone.classList.add('has-file');
|
|
$('file-name').classList.remove('d-none');
|
|
$('fn-text').textContent = f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)';
|
|
$('parse-btn').disabled = false;
|
|
$('parse-error').classList.add('d-none');
|
|
// Reset preview
|
|
parsedRows = [];
|
|
showStep('step-empty');
|
|
}
|
|
|
|
// ── Parse ────────────────────────────────────────────────────────────────
|
|
$('parse-btn').addEventListener('click', () => doParseRequest(null));
|
|
|
|
function doParseRequest(colMap) {
|
|
if (!selectedFile) return;
|
|
const accountId = $('account-select').value;
|
|
if (!accountId) {
|
|
$('parse-error').textContent = 'Please select an account first.';
|
|
$('parse-error').classList.remove('d-none');
|
|
return;
|
|
}
|
|
|
|
setParseLoading(true);
|
|
$('parse-error').classList.add('d-none');
|
|
|
|
const fd = new FormData();
|
|
fd.append('file', selectedFile);
|
|
if (colMap) fd.append('col_map', JSON.stringify(colMap));
|
|
|
|
fetch('{{ url_for("bank_import.parse") }}', { method: 'POST', body: fd })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setParseLoading(false);
|
|
if (data.error) {
|
|
$('parse-error').textContent = data.error;
|
|
$('parse-error').classList.remove('d-none');
|
|
return;
|
|
}
|
|
if (data.needs_mapping) {
|
|
buildMappingUI(data.headers);
|
|
showStep('step-mapping');
|
|
return;
|
|
}
|
|
parsedRows = data.rows || [];
|
|
renderPreview(data);
|
|
})
|
|
.catch(err => {
|
|
setParseLoading(false);
|
|
$('parse-error').textContent = 'Request failed: ' + err;
|
|
$('parse-error').classList.remove('d-none');
|
|
});
|
|
}
|
|
|
|
function setParseLoading(on) {
|
|
const btn = $('parse-btn');
|
|
btn.disabled = on;
|
|
if (on) {
|
|
const isPdf = selectedFile && selectedFile.name.toLowerCase().endsWith('.pdf');
|
|
const label = isPdf ? 'Analysing PDF with AI… (may take ~20s)' : 'Parsing…';
|
|
btn.innerHTML =
|
|
'<span class="spinner-border spinner-border-sm me-1"></span>' + label;
|
|
} else {
|
|
btn.innerHTML =
|
|
'<span id="parse-spinner" class="spinner-border spinner-border-sm me-1 d-none"></span>' +
|
|
'<i class="bi bi-search me-1" id="parse-icon"></i>Parse Statement';
|
|
}
|
|
}
|
|
|
|
// ── Column mapping UI ────────────────────────────────────────────────────
|
|
function buildMappingUI(headers) {
|
|
const selectors = ['map-date','map-desc','map-amount','map-debit','map-credit'];
|
|
selectors.forEach(id => {
|
|
const sel = $(id);
|
|
while (sel.options.length > 1) sel.remove(1);
|
|
headers.forEach(h => {
|
|
const opt = document.createElement('option');
|
|
opt.value = h.toLowerCase();
|
|
opt.textContent = h;
|
|
sel.appendChild(opt);
|
|
});
|
|
});
|
|
}
|
|
|
|
$('apply-mapping-btn').addEventListener('click', () => {
|
|
const dateCol = $('map-date').value;
|
|
const descCol = $('map-desc').value;
|
|
const amtCol = $('map-amount').value;
|
|
const debitCol = $('map-debit').value;
|
|
const credCol = $('map-credit').value;
|
|
const sign = $('map-sign').value === '1';
|
|
|
|
if (!dateCol || !descCol) {
|
|
$('mapping-error').textContent = 'Date and Description columns are required.';
|
|
$('mapping-error').classList.remove('d-none');
|
|
return;
|
|
}
|
|
if (!amtCol && !debitCol && !credCol) {
|
|
$('mapping-error').textContent = 'At least one of Amount, Debit, or Credit column is required.';
|
|
$('mapping-error').classList.remove('d-none');
|
|
return;
|
|
}
|
|
$('mapping-error').classList.add('d-none');
|
|
|
|
const colMap = {
|
|
date: dateCol, description: descCol,
|
|
amount: amtCol || null, debit: debitCol || null, credit: credCol || null,
|
|
positive_is_expense: sign,
|
|
};
|
|
doParseRequest(colMap);
|
|
});
|
|
|
|
// ── Preview table ────────────────────────────────────────────────────────
|
|
function renderPreview(data) {
|
|
const rows = data.rows || [];
|
|
const errors = data.errors || [];
|
|
|
|
$('fmt-label').textContent = data.format_name || 'Detected';
|
|
$('row-count-label').textContent = rows.length + ' transaction' + (rows.length !== 1 ? 's' : '') + ' found';
|
|
|
|
// Warnings
|
|
if (errors.length) {
|
|
$('parse-warnings').classList.remove('d-none');
|
|
$('parse-warnings-list').innerHTML = errors.map(e => `<div>${esc(e)}</div>`).join('');
|
|
} else {
|
|
$('parse-warnings').classList.add('d-none');
|
|
}
|
|
|
|
const tbody = $('preview-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!rows.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">No transactions found in this file.</td></tr>';
|
|
showStep('step-preview');
|
|
updateImportCount();
|
|
return;
|
|
}
|
|
|
|
rows.forEach((row, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.idx = idx;
|
|
|
|
// Checkbox
|
|
const tdChk = document.createElement('td');
|
|
tdChk.style.paddingLeft = '16px';
|
|
const chk = document.createElement('input');
|
|
chk.type = 'checkbox'; chk.checked = true; chk.className = 'row-chk';
|
|
chk.addEventListener('change', () => {
|
|
tr.classList.toggle('row-unchecked', !chk.checked);
|
|
updateImportCount();
|
|
});
|
|
tdChk.appendChild(chk);
|
|
tr.appendChild(tdChk);
|
|
|
|
// Date
|
|
tr.appendChild(tdText(formatDate(row.date), 'color:var(--muted);font-size:12px;white-space:nowrap;'));
|
|
|
|
// Description
|
|
const tdDesc = document.createElement('td');
|
|
tdDesc.textContent = row.description;
|
|
tdDesc.style.maxWidth = '220px';
|
|
tdDesc.style.overflow = 'hidden';
|
|
tdDesc.style.textOverflow = 'ellipsis';
|
|
tdDesc.style.whiteSpace = 'nowrap';
|
|
tr.appendChild(tdDesc);
|
|
|
|
// Type badge
|
|
const tdType = document.createElement('td');
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge ' + (row.transaction_type === 'income' ? 'badge-income' : 'badge-expense');
|
|
badge.style.fontSize = '11px';
|
|
badge.textContent = row.transaction_type === 'income' ? 'Income' : 'Expense';
|
|
tdType.appendChild(badge);
|
|
tr.appendChild(tdType);
|
|
|
|
// Amount
|
|
const tdAmt = document.createElement('td');
|
|
tdAmt.className = 'mono-amt ' + (row.transaction_type === 'income' ? 'text-income' : 'text-expense');
|
|
tdAmt.textContent = (row.transaction_type === 'income' ? '+' : '-') + fmtAmt(row.amount);
|
|
tr.appendChild(tdAmt);
|
|
|
|
// Category dropdown
|
|
const tdCat = document.createElement('td');
|
|
const sel = buildCategorySelect(row.category_id, row.transaction_type);
|
|
sel.dataset.idx = idx;
|
|
tdCat.appendChild(sel);
|
|
tr.appendChild(tdCat);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
updateImportCount();
|
|
showStep('step-preview');
|
|
}
|
|
|
|
function buildCategorySelect(selectedId, txnType) {
|
|
const sel = document.createElement('select');
|
|
sel.className = 'form-select form-select-sm cat-select';
|
|
|
|
const blank = document.createElement('option');
|
|
blank.value = ''; blank.textContent = '— Uncategorized —';
|
|
sel.appendChild(blank);
|
|
|
|
CATEGORIES.forEach(cat => {
|
|
if (cat.type !== 'both' && cat.type !== txnType) return;
|
|
const opt = document.createElement('option');
|
|
opt.value = cat.id;
|
|
opt.textContent = cat.name;
|
|
if (cat.id == selectedId) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
return sel;
|
|
}
|
|
|
|
function updateImportCount() {
|
|
const checked = document.querySelectorAll('#preview-body .row-chk:checked').length;
|
|
$('import-count').textContent = checked;
|
|
$('import-btn').disabled = checked === 0;
|
|
}
|
|
|
|
// Header checkbox — select/deselect all
|
|
$('header-checkbox').addEventListener('change', function () {
|
|
document.querySelectorAll('#preview-body .row-chk').forEach(chk => {
|
|
chk.checked = this.checked;
|
|
chk.closest('tr').classList.toggle('row-unchecked', !this.checked);
|
|
});
|
|
updateImportCount();
|
|
});
|
|
$('select-all-btn').addEventListener('click', () => {
|
|
document.querySelectorAll('#preview-body .row-chk').forEach(chk => {
|
|
chk.checked = true; chk.closest('tr').classList.remove('row-unchecked');
|
|
});
|
|
$('header-checkbox').checked = true;
|
|
updateImportCount();
|
|
});
|
|
$('select-none-btn').addEventListener('click', () => {
|
|
document.querySelectorAll('#preview-body .row-chk').forEach(chk => {
|
|
chk.checked = false; chk.closest('tr').classList.add('row-unchecked');
|
|
});
|
|
$('header-checkbox').checked = false;
|
|
updateImportCount();
|
|
});
|
|
|
|
// ── Import ────────────────────────────────────────────────────────────────
|
|
$('import-btn').addEventListener('click', () => {
|
|
const accountId = $('account-select').value;
|
|
const skipDupes = $('skip-dupes').checked;
|
|
|
|
// Collect selected rows with current category selections
|
|
const rows = [];
|
|
document.querySelectorAll('#preview-body tr').forEach(tr => {
|
|
const chk = tr.querySelector('.row-chk');
|
|
if (!chk || !chk.checked) return;
|
|
const idx = parseInt(tr.dataset.idx);
|
|
const row = { ...parsedRows[idx] };
|
|
const sel = tr.querySelector('.cat-select');
|
|
row.category_id = sel && sel.value ? parseInt(sel.value) : null;
|
|
rows.push(row);
|
|
});
|
|
|
|
if (!rows.length) return;
|
|
|
|
setImportLoading(true);
|
|
$('import-error').classList.add('d-none');
|
|
|
|
fetch('{{ url_for("bank_import.confirm_import") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': CSRF,
|
|
},
|
|
body: JSON.stringify({ account_id: parseInt(accountId), skip_dupes: skipDupes, rows }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
setImportLoading(false);
|
|
if (data.error) {
|
|
$('import-error').textContent = data.error;
|
|
$('import-error').classList.remove('d-none');
|
|
return;
|
|
}
|
|
$('done-title').textContent =
|
|
data.imported + ' transaction' + (data.imported !== 1 ? 's' : '') + ' imported successfully';
|
|
$('done-subtitle').textContent =
|
|
data.skipped > 0 ? data.skipped + ' duplicate(s) skipped.' : 'All transactions were new.';
|
|
showStep('step-done');
|
|
})
|
|
.catch(err => {
|
|
setImportLoading(false);
|
|
$('import-error').textContent = 'Request failed: ' + err;
|
|
$('import-error').classList.remove('d-none');
|
|
});
|
|
});
|
|
|
|
function setImportLoading(on) {
|
|
$('import-spinner').classList.toggle('d-none', !on);
|
|
$('import-icon').classList.toggle('d-none', on);
|
|
$('import-btn').disabled = on;
|
|
}
|
|
|
|
// ── Import another ────────────────────────────────────────────────────────
|
|
$('import-another-btn').addEventListener('click', () => {
|
|
selectedFile = null;
|
|
parsedRows = [];
|
|
fileInput.value = '';
|
|
dropZone.classList.remove('has-file');
|
|
$('file-name').classList.add('d-none');
|
|
$('parse-btn').disabled = true;
|
|
$('parse-error').classList.add('d-none');
|
|
showStep('step-empty');
|
|
});
|
|
|
|
// ── Utility ───────────────────────────────────────────────────────────────
|
|
function esc(s) {
|
|
return String(s)
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
function tdText(text, style) {
|
|
const td = document.createElement('td');
|
|
td.textContent = text;
|
|
if (style) td.style.cssText = style;
|
|
return td;
|
|
}
|
|
function formatDate(iso) {
|
|
const d = new Date(iso + 'T00:00:00');
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
function fmtAmt(n) {
|
|
return parseFloat(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
// Init
|
|
showStep('step-empty');
|
|
})();
|
|
</script>
|
|
{% endblock %}
|