Files
Personal-Finance-Management/app/templates/transactions/form.html
T
2026-06-05 17:50:46 -04:00

472 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block extra_css %}
#ocrDropZone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all .2s;
background: #f8fafc;
}
#ocrDropZone.dragover { border-color: #3b82f6; background: #eff6ff; }
#ocrDropZone.loading { border-color: #818cf8; background: #f5f3ff; }
#ocrDropZone.success { border-color: #10b981; background: #f0fdf4; }
#ocrDropZone.error { border-color: #ef4444; background: #fef2f2; }
.ocr-field-highlight { animation: ocr-flash .6s ease; }
@keyframes ocr-flash { 0%,100%{background:transparent} 50%{background:#d1fae5} }
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-12 col-md-9 col-lg-7">
<!-- OCR Receipt Scanner -->
<div class="pcard mb-3" style="border-left: 4px solid #818cf8;">
<div class="d-flex justify-content-between align-items-center mb-2">
<div style="font-size:13px;font-weight:600;color:#4f46e5;">
<i class="bi bi-stars me-1" style="color:#818cf8;"></i>AI Receipt Scanner
</div>
<button type="button" onclick="toggleOcr()" id="ocrToggleBtn"
style="background:none;border:none;font-size:12px;color:#818cf8;cursor:pointer;">
<i class="bi bi-chevron-down" id="ocrChevron"></i>
</button>
</div>
<div id="ocrPanel" {% if not txn %}style="display:block;"{% else %}style="display:none;"{% endif %}>
<div id="ocrDropZone" onclick="document.getElementById('ocrFileInput').click()"
ondragover="event.preventDefault();this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')"
ondrop="handleDrop(event)">
<input type="file" id="ocrFileInput" accept=".jpg,.jpeg,.png,.gif,.webp"
style="display:none;" onchange="handleOcrFile(this.files[0])">
<div id="ocrDropContent">
<i class="bi bi-receipt" style="font-size:1.8rem;color:#a5b4fc;"></i>
<div style="font-size:13px;font-weight:500;color:#4f46e5;margin-top:8px;">
Drop receipt image here or click to upload
</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">
JPG, PNG, GIF, WEBP · max 10MB · Powered by Groq vision
</div>
</div>
</div>
<div id="ocrStatus" style="font-size:12px;margin-top:8px;display:none;"></div>
</div>
</div>
<!-- Transaction Form -->
<div class="pcard" id="txnCard" style="border-top: 4px solid {% if txn_type=='income' %}var(--income){% else %}var(--expense){% endif %};">
<form method="POST" novalidate id="txnForm">
{{ form.hidden_tag() }}
{{ form.transaction_type() }}
<input type="hidden" name="next" value="{{ next_url or '' }}">
<!-- Type toggle — only for income/expense (not transfer) -->
<div class="mb-3">
<label class="form-label fw-medium" style="font-size:13px;">Type</label>
<div class="d-flex gap-2">
<button type="button" id="toggleIncome" onclick="setTxnType('income')"
class="btn btn-sm flex-fill {% if txn_type=='income' %}btn-success{% else %}btn-outline-secondary{% endif %}"
style="font-size:13px;">
<i class="bi bi-arrow-down-circle me-1"></i>Income
</button>
<button type="button" id="toggleExpense" onclick="setTxnType('expense')"
class="btn btn-sm flex-fill {% if txn_type=='expense' %}btn-danger{% else %}btn-outline-secondary{% endif %}"
style="font-size:13px;">
<i class="bi bi-arrow-up-circle me-1"></i>Expense
</button>
</div>
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.description(class="form-control" + (" is-invalid" if form.description.errors else ""),
placeholder="What was this for?", id="field_description") }}
{% for e in form.description.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
{{ form.amount.label(class="form-label fw-medium", style="font-size:13px;") }}
<div class="input-group">
<span class="input-group-text" style="font-size:13px;">{{ current_user.currency_symbol }}</span>
{{ form.amount(class="form-control" + (" is-invalid" if form.amount.errors else ""),
placeholder="0.00", id="field_amount") }}
</div>
{% for e in form.amount.errors %}<div class="text-danger mt-1" style="font-size:12px;">{{ e }}</div>{% endfor %}
</div>
<div class="col-6">
{{ form.date.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.date(class="form-control" + (" is-invalid" if form.date.errors else ""),
id="field_date") }}
</div>
</div>
<div class="mb-3">
{{ form.account_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.account_id(class="form-select" + (" is-invalid" if form.account_id.errors else "")) }}
{% for e in form.account_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.category_id.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.category_id(class="form-select", id="field_category") }}
</div>
<div class="mb-3">
{{ form.notes.label(class="form-label fw-medium", style="font-size:13px;") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional notes", id="field_notes") }}
</div>
<!-- Duplicate warning -->
<div id="dupe-banner" style="display:none;background:#fef9c3;border:1px solid #fcd34d;border-radius:8px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#854d0e;align-items:center;gap-8px;">
<i class="bi bi-exclamation-triangle-fill me-2" style="color:#d97706;"></i>
<span id="dupe-text"></span>
<button type="button" onclick="document.getElementById('dupe-banner').style.display='none';" style="background:none;border:none;font-size:14px;color:#854d0e;cursor:pointer;padding:0;margin-left:auto;">×</button>
</div>
<div class="d-flex gap-2">
<button type="submit" id="submitBtn"
class="btn {% if txn_type=='income' %}btn-success{% else %}btn-danger{% endif %}">
Save {{ txn_type | title }}
</button>
<a id="cancelLink" href="{{ url_for('transactions.index', tab=txn_type) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
{# Receipt section — kept OUTSIDE #txnForm to avoid nested-form breakage #}
{% if txn and txn.receipt %}
<div class="mt-3 p-3" style="background:#f8fafc;border-radius:8px;">
<div class="d-flex justify-content-between align-items-center">
<div style="font-size:13px;">
<i class="bi bi-paperclip me-1 text-muted"></i>
<a href="{{ url_for('settings.view_receipt', filename=txn.receipt.filename) }}" target="_blank">
{{ txn.receipt.original_filename }}
</a>
</div>
<div class="d-flex gap-2">
<button type="button" onclick="ocrExistingReceipt('{{ txn.receipt.filename }}')"
class="btn btn-sm" style="font-size:11px;background:#ede9fe;color:#5b21b6;border:none;">
<i class="bi bi-stars me-1"></i>Re-extract
</button>
<form method="POST" action="{{ url_for('settings.delete_receipt', txn_id=txn.id) }}"
onsubmit="return confirm('Delete receipt?')" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Remove</button>
</form>
</div>
</div>
</div>
{% elif txn %}
<div class="mt-3">
<label class="form-label fw-medium" style="font-size:13px;">Receipt</label>
<form method="POST" action="{{ url_for('settings.upload_receipt', txn_id=txn.id) }}"
enctype="multipart/form-data" id="receiptUploadForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group input-group-sm">
<input type="file" name="receipt" id="receiptFileEdit"
class="form-control" accept=".png,.jpg,.jpeg,.gif,.pdf,.webp"
style="font-size:12px;" onchange="autoOcrOnUpload(this)">
<button type="submit" class="btn btn-outline-secondary" style="font-size:12px;">Upload</button>
</div>
<small class="text-muted" style="font-size:11px;">PNG, JPG, WEBP, PDF — max 10MB</small>
</form>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const CSRF = '{{ csrf_token() }}';
const categoryOptions = [...document.getElementById('field_category').options];
// ── OCR panel toggle ─────────────────────────────────────────────────────────
function toggleOcr() {
const panel = document.getElementById('ocrPanel');
const icon = document.getElementById('ocrChevron');
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
icon.className = open ? 'bi bi-chevron-down' : 'bi bi-chevron-up';
}
// ── Drag & drop ───────────────────────────────────────────────────────────────
function handleDrop(e) {
e.preventDefault();
document.getElementById('ocrDropZone').classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) handleOcrFile(file);
}
// ── Main OCR handler ──────────────────────────────────────────────────────────
function handleOcrFile(file) {
if (!file) return;
const allowed = ['image/jpeg','image/png','image/gif','image/webp'];
if (!allowed.includes(file.type)) {
setOcrStatus('error', 'Unsupported format. Use JPG, PNG, GIF or WEBP.');
return;
}
runOcr(file);
}
function runOcr(file) {
const zone = document.getElementById('ocrDropZone');
zone.className = 'loading';
document.getElementById('ocrDropContent').innerHTML = `
<div style="color:#818cf8;font-size:13px;">
<span style="display:inline-block;animation:spin .8s linear infinite;font-size:1.5rem;"><i class="bi bi-stars"></i></span><br>
<span style="margin-top:8px;display:block;">Scanning receipt with AI…</span>
</div>`;
setOcrStatus('', '', false);
const fd = new FormData();
fd.append('receipt', file);
fd.append('csrf_token', CSRF);
fetch('/transactions/ocr', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.error) {
zone.className = 'error';
resetOcrZone();
setOcrStatus('error', '⚠ ' + data.error);
return;
}
zone.className = 'success';
resetOcrZone(true);
fillForm(data);
setOcrStatus('success',
`✓ Extracted: ${data.description || 'receipt'} · ${data.amount ? '{{ current_user.currency_symbol }}' + data.amount : ''} · ${data.category_suggestion || ''}`
);
})
.catch(err => {
zone.className = 'error';
resetOcrZone();
setOcrStatus('error', '⚠ Network error. Please try again.');
});
}
function resetOcrZone(success) {
document.getElementById('ocrDropContent').innerHTML = success
? `<i class="bi bi-check-circle" style="font-size:1.8rem;color:#10b981;"></i>
<div style="font-size:13px;font-weight:500;color:#10b981;margin-top:8px;">Receipt scanned — form filled below</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">Drop another receipt to re-scan</div>`
: `<i class="bi bi-receipt" style="font-size:1.8rem;color:#a5b4fc;"></i>
<div style="font-size:13px;font-weight:500;color:#4f46e5;margin-top:8px;">Drop receipt image here or click to upload</div>
<div style="font-size:11px;color:var(--muted);margin-top:4px;">JPG, PNG, GIF, WEBP · max 10MB</div>`;
}
function setOcrStatus(type, msg, show) {
const el = document.getElementById('ocrStatus');
if (show === false) { el.style.display = 'none'; return; }
el.style.display = msg ? 'block' : 'none';
el.style.color = type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#64748b';
el.textContent = msg;
}
// ── Fill form fields ──────────────────────────────────────────────────────────
function fillForm(data) {
if (data.amount) setField('field_amount', data.amount);
if (data.date) setField('field_date', data.date);
if (data.description) setField('field_description', data.description);
if (data.notes) setField('field_notes', data.notes);
// Category
if (data.category_id) {
const sel = document.getElementById('field_category');
if (sel) {
sel.value = String(data.category_id);
flash(sel);
}
}
// Scroll to form
document.getElementById('txnForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function setField(id, val) {
const el = document.getElementById(id);
if (el) { el.value = val; flash(el); }
}
function flash(el) {
el.classList.remove('ocr-field-highlight');
void el.offsetWidth; // reflow
el.classList.add('ocr-field-highlight');
setTimeout(() => el.classList.remove('ocr-field-highlight'), 700);
}
// ── Re-extract from already-uploaded receipt (edit mode) ──────────────────────
function ocrExistingReceipt(filename) {
const btn = event.target.closest('button');
btn.innerHTML = '<i class="bi bi-stars me-1" style="animation:spin .7s linear infinite;display:inline-block;"></i>Scanning…';
btn.disabled = true;
fetch('/transactions/ocr-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({ filename: filename }),
})
.then(r => r.json())
.then(data => {
btn.innerHTML = '<i class="bi bi-stars me-1"></i>Re-extract';
btn.disabled = false;
if (data.error) { alert('OCR error: ' + data.error); return; }
fillForm(data);
const banner = document.createElement('div');
banner.style.cssText = 'font-size:12px;color:#10b981;margin-top:6px;';
banner.textContent = '✓ Fields updated from receipt';
btn.closest('.mb-3').appendChild(banner);
setTimeout(() => banner.remove(), 3000);
})
.catch(() => {
btn.innerHTML = '<i class="bi bi-stars me-1"></i>Re-extract';
btn.disabled = false;
alert('Network error. Please try again.');
});
}
// ── Client-side form validation ───────────────────────────────────────────────
document.getElementById('txnForm').addEventListener('submit', function(e) {
let valid = true;
const desc = document.getElementById('field_description');
if (!desc.value.trim()) {
desc.classList.add('is-invalid');
if (!desc.nextElementSibling || !desc.nextElementSibling.classList.contains('invalid-feedback')) {
const err = document.createElement('div');
err.className = 'invalid-feedback';
err.textContent = 'Description is required.';
desc.after(err);
}
valid = false;
} else {
desc.classList.remove('is-invalid');
}
const amt = document.getElementById('field_amount');
const amtVal = parseFloat(amt.value);
const amtParent = amt.closest('.input-group') || amt;
if (!amt.value || isNaN(amtVal) || amtVal <= 0) {
amt.classList.add('is-invalid');
let errEl = amtParent.parentElement.querySelector('.amt-error');
if (!errEl) {
errEl = document.createElement('div');
errEl.className = 'text-danger mt-1 amt-error';
errEl.style.fontSize = '12px';
amtParent.after(errEl);
}
errEl.textContent = amtVal < 0 ? 'Amount must be positive.' : 'Amount is required.';
valid = false;
} else {
amt.classList.remove('is-invalid');
const errEl = amtParent.parentElement.querySelector('.amt-error');
if (errEl) errEl.remove();
}
const dt = document.getElementById('field_date');
if (!dt.value) {
dt.classList.add('is-invalid');
valid = false;
} else {
dt.classList.remove('is-invalid');
}
if (!valid) e.preventDefault();
});
// Clear validation state on input
['field_description', 'field_amount', 'field_date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => el.classList.remove('is-invalid'));
});
// ── Auto-OCR when receipt is selected in edit form ────────────────────────────
function autoOcrOnUpload(input) {
const file = input.files[0];
if (!file) return;
const imageTypes = ['image/jpeg','image/png','image/gif','image/webp'];
if (!imageTypes.includes(file.type)) return; // PDF — skip OCR
// Show OCR panel and run
const panel = document.getElementById('ocrPanel');
if (panel) { panel.style.display = 'block'; document.getElementById('ocrChevron').className = 'bi bi-chevron-up'; }
runOcr(file);
}
// ── Transaction type toggle ───────────────────────────────────────────────────
const incomeCats = {{ income_cats | tojson }};
const expenseCats = {{ expense_cats | tojson }};
function setTxnType(type) {
// Update hidden field
document.querySelector('[name=transaction_type]').value = type;
// Toggle button styles
const incBtn = document.getElementById('toggleIncome');
const expBtn = document.getElementById('toggleExpense');
incBtn.className = 'btn btn-sm flex-fill ' + (type === 'income' ? 'btn-success' : 'btn-outline-secondary');
expBtn.className = 'btn btn-sm flex-fill ' + (type === 'expense' ? 'btn-danger' : 'btn-outline-secondary');
// Card border + submit button
document.getElementById('txnCard').style.borderTopColor = type === 'income' ? 'var(--income)' : 'var(--expense)';
const submitBtn = document.getElementById('submitBtn');
submitBtn.className = 'btn ' + (type === 'income' ? 'btn-success' : 'btn-danger');
submitBtn.textContent = 'Save ' + (type === 'income' ? 'Income' : 'Expense');
// Update cancel link tab param
const cancelLink = document.getElementById('cancelLink');
if (cancelLink) {
cancelLink.href = cancelLink.href.replace(/([\?&]tab=)[^&]*/, '$1' + type);
}
// Swap category options, preserving current selection if still valid
const sel = document.getElementById('field_category');
const prevVal = sel.value;
const cats = type === 'income' ? incomeCats : expenseCats;
sel.innerHTML = '<option value="">— None —</option>' +
cats.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
if (prevVal && cats.some(c => c.id === prevVal)) {
sel.value = prevVal;
}
}
// ── Duplicate detection ───────────────────────────────────────────────────────
{% if not txn %}
const DUPE_EXCLUDE_ID = '';
{% else %}
const DUPE_EXCLUDE_ID = '{{ txn.id }}';
{% endif %}
let _dupeTimer = null;
function checkDuplicate() {
const amt = document.getElementById('field_amount').value;
const dt = document.getElementById('field_date').value;
const typ = document.querySelector('[name=transaction_type]').value;
const banner = document.getElementById('dupe-banner');
if (!amt || !dt || parseFloat(amt) <= 0) { banner.style.display = 'none'; return; }
clearTimeout(_dupeTimer);
_dupeTimer = setTimeout(() => {
const url = `/transactions/api/check-duplicate?date=${dt}&amount=${amt}&type=${typ}${DUPE_EXCLUDE_ID ? '&exclude_id=' + DUPE_EXCLUDE_ID : ''}`;
fetch(url).then(r => r.json()).then(data => {
if (data.duplicates && data.duplicates.length > 0) {
const list = data.duplicates.map(d => `"${d.description}" on ${d.date}${d.account ? ' (' + d.account + ')' : ''}`).join('; ');
document.getElementById('dupe-text').textContent = `Possible duplicate: ${list}`;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
}).catch(() => {});
}, 600);
}
['field_amount', 'field_date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', checkDuplicate);
});
</script>
{% endblock %}