read.markets/app/templates/upload.html
Giorgio Gilestro 8a155ef157 phase B (2/2): CSV upload endpoint + drag-drop UI
Completes Phase B. The full alternative-onboarding flow is now end-to-end:
drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot
+ Position rows, all without ever asking the user for broker credentials.

- persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves
  each Slice via InstrumentMap, writes Portfolio + Snapshot + Position
  rows. Unmapped slices are still persisted using their CSV values and
  surfaced in the response for the UI to warn about.
- POST /api/portfolios/upload: multipart endpoint accepting CSV file +
  optional portfolio_name + currency. 2 MiB cap. Returns import summary.
- /upload page with drag-drop dropzone, file input fallback, and inline
  result panel showing invested/value/result + unmapped-slice warnings.
- New "Import" link in the header nav.

Verified end-to-end against the real T212 export: all 13 positions land
with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies
listing the heuristic resolver picks), zero unmapped slices, totals
reconcile to the penny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:00:42 +01:00

157 lines
6.2 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 %}Cassandra · Import Portfolio{% endblock %}
{% block main %}
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
<div class="panel-header">
<span class="title">Import portfolio (Trading 212 CSV)</span>
<span class="meta">no broker credentials required</span>
</div>
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
<p style="color: var(--muted); font-size: 12.5px; margin: 0 0 14px; line-height: 1.6;">
Export your pie from the T212 web app
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
and drop the CSV here. We resolve each Slice to its Yahoo ticker via
a catalogue we maintain in the background.
</p>
<form id="upload-form" autocomplete="off">
<div id="drop-zone" class="dz">
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
<div class="dz__icon"></div>
<div class="dz__label">Drop a T212 pie CSV here</div>
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 2 MB</div>
<div class="dz__filename" id="dz-filename"></div>
</div>
<div class="form-row" style="margin-top: 14px;">
<label for="portfolio-name">Portfolio name (optional)</label>
<input type="text" id="portfolio-name" name="portfolio_name"
placeholder="auto-derived from CSV's Total row" maxlength="64">
</div>
<div class="form-row" style="margin-top: 6px;">
<label for="currency">Account currency</label>
<select id="currency" name="currency">
<option value="GBP">GBP</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<button id="submit-btn" type="submit" disabled>Import</button>
</form>
<div id="result" class="result" hidden></div>
</div>
</section>
<script>
(function () {
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
var browseLink = document.getElementById('browse-link');
var filenameEl = document.getElementById('dz-filename');
var submitBtn = document.getElementById('submit-btn');
var form = document.getElementById('upload-form');
var resultEl = document.getElementById('result');
function setFile(file) {
if (!file) return;
var dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
submitBtn.disabled = false;
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) setFile(fileInput.files[0]);
});
['dragenter', 'dragover'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.add('dz--over');
});
});
['dragleave', 'drop'].forEach(function (ev) {
dropZone.addEventListener(ev, function (e) {
e.preventDefault(); e.stopPropagation();
dropZone.classList.remove('dz--over');
});
});
dropZone.addEventListener('drop', function (e) {
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
});
dropZone.addEventListener('click', function (e) {
if (e.target.tagName !== 'A') fileInput.click();
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
if (!fileInput.files[0]) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Importing…';
resultEl.hidden = true;
resultEl.className = 'result';
var fd = new FormData();
fd.append('file', fileInput.files[0]);
var name = document.getElementById('portfolio-name').value.trim();
if (name) fd.append('portfolio_name', name);
fd.append('currency', document.getElementById('currency').value);
try {
var r = await fetch('/api/portfolios/upload', { method: 'POST', body: fd });
var data = await r.json();
if (!r.ok) {
renderError(data.detail || ('HTTP ' + r.status));
return;
}
renderSuccess(data);
} catch (err) {
renderError(err.message);
} finally {
submitBtn.textContent = 'Import';
submitBtn.disabled = false;
}
});
function fmt(n) {
return (n === null || n === undefined) ? '—' : Number(n).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function renderSuccess(d) {
var unmappedTxt = d.unmapped && d.unmapped.length
? '<div class="result__warn"><strong>' + d.unmapped.length + ' unmapped slice(s):</strong> '
+ d.unmapped.map(function(s) { return '<code>' + s + '</code>'; }).join(', ')
+ ' — these wont get live prices until the catalogue is extended.</div>'
: '<div class="result__row neu">All slices resolved to Yahoo tickers.</div>';
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">▸ Imported <strong>' + d.portfolio_name + '</strong>'
+ (d.is_new_portfolio ? ' <span class="result__tag">new</span>' : ' <span class="result__tag">new snapshot</span>')
+ '</div>'
+ '<div class="result__grid">'
+ '<div><div class="k">Positions</div><div class="v">' + d.positions + '</div></div>'
+ '<div><div class="k">Invested</div><div class="v">' + fmt(d.invested) + '</div></div>'
+ '<div><div class="k">Value</div><div class="v">' + fmt(d.value) + '</div></div>'
+ '<div><div class="k">Result</div><div class="v ' + (d.result >= 0 ? 'pos' : 'neg') + '">'
+ (d.result >= 0 ? '+' : '') + fmt(d.result) + '</div></div>'
+ '</div>'
+ unmappedTxt
+ '<div class="result__row"><a href="/">Back to dashboard →</a></div>';
resultEl.hidden = false;
}
function renderError(msg) {
resultEl.className = 'result result--err';
resultEl.innerHTML = '<div class="result__head">✕ Import failed</div><div class="result__row">'
+ String(msg).replace(/[<>]/g, '') + '</div>';
resultEl.hidden = false;
}
})();
</script>
{% endblock %}