Gonçalo Rodrigues 2170457528 feat: duplicate detection on CSV import
Compute a sha256 fingerprint (date|description|amount|account_id, first
16 hex chars) for every CSV row and store it as bank_ref. At preview
time, existing fingerprints are fetched and matching rows are shown
greyed out with a "duplicate" label. At confirm time, those rows are
silently skipped — only truly new transactions are inserted.

If every row is a duplicate the user is redirected with ?notice=all_duplicates
instead of inserting an empty batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 18:15:23 +01:00

151 lines
6.0 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.

{{define "content"}}
{{$d := .}}
<h1 style="margin-bottom:24px;">Import</h1>
{{if $d.Error}}
<div class="card" style="border-color:var(--red); background:var(--red-dim);">
<span style="color:var(--red); font-size:13.5px;">⚠ {{$d.Error}}</span>
</div>
{{end}}
{{if $d.Preview}}
<!-- Preview table -->
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
<div>
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0;">Preview</h2>
<p class="text-muted" style="margin-top:3px;">
{{$d.Preview.Total}} rows
{{if $d.DuplicateCount}}
<span style="color:var(--yellow, #f59e0b); font-weight:600;">{{$d.DuplicateCount}} already imported</span> (shown greyed out, will be skipped)
{{end}}
</p>
</div>
<a href="/import" class="btn btn-outline btn-sm">← Back</a>
</div>
<form method="POST" action="/import/confirm">
<input type="hidden" name="account_id" value="{{$d.Preview.AccountID}}">
<input type="hidden" name="format" value="{{$d.SelectedFormat}}">
<input type="hidden" name="raw_data" value="{{$d.RawData}}">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th class="text-right">Amount</th>
<th>Category</th>
</tr>
</thead>
<tbody>
{{range $i, $row := $d.Preview.Rows}}
<tr {{if $row.Duplicate}}style="opacity:0.4;"{{end}}>
<td style="white-space:nowrap; color:var(--text2);">{{$row.Date}}</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{$row.Description}}
{{if $row.Duplicate}}<span style="font-size:11px; font-weight:600; color:var(--muted); margin-left:6px;">duplicate</span>{{end}}
</td>
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
{{if lt $row.AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs $row.AmountCents)}}
</td>
<td>
{{if $row.Duplicate}}
<span style="font-size:12px; color:var(--muted);"></span>
{{else}}
<select name="categories" class="cat-select" data-selected="{{$row.Category}}"
style="font-size:12.5px; padding:5px 8px; border:1.5px solid var(--border2);
border-radius:6px; background:var(--bg2); color:var(--text);
border-left-width:4px; cursor:pointer; min-width:130px;">
{{range $cat := $d.Categories}}
<option value="{{$cat}}" {{if eq $cat $row.Category}}selected{{end}}>{{$cat}}</option>
{{end}}
</select>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
<button type="submit" class="btn btn-primary">✓ Confirm Import</button>
<a href="/import" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
<script>
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
document.querySelectorAll('.cat-select').forEach(sel => {
function paint() {
const color = catColors[sel.value] || '';
sel.style.borderLeftColor = color || 'var(--border2)';
}
sel.addEventListener('change', paint);
paint();
});
</script>
{{else}}
<!-- Upload forms -->
<div class="grid-2" style="align-items:start;">
<div class="card animate-on-scroll">
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
Bank Transactions
</h2>
<form method="POST" action="/import/preview" enctype="multipart/form-data">
<div class="form-group">
<label>Account</label>
<select name="account_id" required>
<option value="">Select account…</option>
{{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>Bank / Format</label>
<select name="format">
<option value="cgd">Caixa Geral de Depósitos (CGD)</option>
<option value="traderepublic">Trade Republic Card</option>
<option value="generic">Generic CSV</option>
</select>
</div>
<div class="form-group">
<label>CSV File</label>
<input type="file" name="file" accept=".csv,.txt" required
style="padding:10px; cursor:pointer; font-size:13px;">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Preview Import</button>
</form>
</div>
<div class="card animate-on-scroll">
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
Securities Trades
</h2>
<p class="text-muted" style="margin-bottom:16px; font-size:13px; line-height:1.6;">
Upload your Trade Republic securities CSV to import buy/sell trades into your portfolio.
</p>
<form method="POST" action="/import/securities" enctype="multipart/form-data">
<div class="form-group">
<label>Trade Republic Securities CSV</label>
<input type="file" name="file" accept=".csv,.txt" required
style="padding:10px; cursor:pointer; font-size:13px;">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Import Trades</button>
</form>
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border);">
<p class="text-muted" style="font-size:12px; line-height:1.6;">
After importing, visit <a href="/portfolio" style="color:var(--accent);">Portfolio</a> to see live prices and P&L.
</p>
</div>
</div>
</div>
{{end}}
{{end}}