Files
Personal-Finance-Management/app/templates/bank_import/index.html
T

632 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 &amp; 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 &amp; 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',
headers: { 'X-CSRFToken': CSRF },
body: fd,
})
.then(r => {
if (!r.ok && !r.headers.get('content-type')?.includes('application/json')) {
throw new Error(`Server error ${r.status} — check server logs for details.`);
}
return 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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 %}