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>
157 lines
6.2 KiB
HTML
157 lines
6.2 KiB
HTML
{% 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 won’t 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 %}
|