phase-7: code restructure
This commit is contained in:
@@ -1,751 +1,55 @@
|
||||
<script lang="ts">
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import { addHolding, removeHolding } from '$lib/api.js';
|
||||
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
|
||||
import type { Signal, MarketContext as MarketContextType, PortfolioHolding } from '$lib/types.js';
|
||||
import { portfolioStore } from '$lib/stores/portfolio.store.svelte.js';
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
import AddHoldingForm from '$lib/portfolio/AddHoldingForm.svelte';
|
||||
import AdviceTable from '$lib/portfolio/AdviceTable.svelte';
|
||||
import AccountsTable from '$lib/portfolio/AccountsTable.svelte';
|
||||
|
||||
interface AdviceRow {
|
||||
ticker: string;
|
||||
type: string;
|
||||
source: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
currentPrice: string | null;
|
||||
marketValue: string | null;
|
||||
gainLossPct: string | null;
|
||||
signal: Signal | null;
|
||||
advice: string;
|
||||
reason: string;
|
||||
}
|
||||
const p = portfolioStore;
|
||||
|
||||
interface PortfolioData {
|
||||
advice: AdviceRow[];
|
||||
marketContext: MarketContextType | null;
|
||||
personalFinance: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
let { data: _data } = $props(); // unused — we load client-side
|
||||
|
||||
let data: PortfolioData | null = $state(null);
|
||||
let loading: boolean = $state(true);
|
||||
let refreshing: boolean = $state(false);
|
||||
let loadError: string | null = $state(null);
|
||||
|
||||
// ── Add holding form (new holdings only) ────────────────────────────────────
|
||||
let formOpen: boolean = $state(false);
|
||||
let saving: boolean = $state(false);
|
||||
let formError: string|null = $state(null);
|
||||
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
|
||||
|
||||
// ── Inline row editing ───────────────────────────────────────────────────────
|
||||
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
|
||||
let inlineEdit: InlineEdit | null = $state(null);
|
||||
let inlineSaving: boolean = $state(false);
|
||||
|
||||
function startInlineEdit(a: AdviceRow) {
|
||||
inlineEdit = {
|
||||
ticker: a.ticker,
|
||||
shares: String(a.shares),
|
||||
costBasis: String(a.costBasis ?? 0),
|
||||
type: a.type ?? 'stock',
|
||||
source: a.source ?? 'Robinhood',
|
||||
};
|
||||
}
|
||||
|
||||
async function saveInlineEdit() {
|
||||
if (!inlineEdit) return;
|
||||
inlineSaving = true;
|
||||
try {
|
||||
const updated = {
|
||||
ticker: inlineEdit.ticker,
|
||||
shares: parseFloat(inlineEdit.shares),
|
||||
costBasis: parseFloat(inlineEdit.costBasis) || 0,
|
||||
type: inlineEdit.type,
|
||||
source: inlineEdit.source,
|
||||
};
|
||||
await addHolding(updated);
|
||||
|
||||
// Optimistic update — patch the row immediately, don't wait for Yahoo
|
||||
if (data?.advice) {
|
||||
data = {
|
||||
...data,
|
||||
advice: data.advice.map(a =>
|
||||
a.ticker === updated.ticker
|
||||
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
|
||||
marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)),
|
||||
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
|
||||
: a
|
||||
),
|
||||
};
|
||||
}
|
||||
inlineEdit = null;
|
||||
fetchPortfolioData(false); // background: update prices + signals
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
} finally {
|
||||
inlineSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = !formOpen;
|
||||
formError = null;
|
||||
inlineEdit = null;
|
||||
}
|
||||
|
||||
async function submitHolding() {
|
||||
formError = null;
|
||||
const ticker = form.ticker.trim().toUpperCase();
|
||||
const shares = parseFloat(form.shares);
|
||||
const costBasis = parseFloat(form.costBasis) || 0;
|
||||
if (!ticker) { formError = 'Ticker is required.'; return; }
|
||||
if (!shares || shares <= 0) { formError = 'Shares must be greater than 0.'; return; }
|
||||
saving = true;
|
||||
try {
|
||||
await addHolding({ ticker, shares, costBasis, type: form.type, source: form.source });
|
||||
|
||||
// Optimistic update — add placeholder row immediately
|
||||
const existing = data?.advice?.find(a => a.ticker === ticker);
|
||||
if (data?.advice && !existing) {
|
||||
data = {
|
||||
...data,
|
||||
advice: [...data.advice, {
|
||||
ticker, shares, costBasis, type: form.type, source: form.source,
|
||||
currentPrice: null, marketValue: null, gainLossPct: null,
|
||||
signal: null, advice: '⏳ Fetching…', reason: 'Screener data loading in background.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
|
||||
formOpen = false;
|
||||
fetchPortfolioData(false); // background: get real price + signal
|
||||
} catch (e) {
|
||||
formError = (e as Error).message;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHolding(ticker: string): Promise<void> {
|
||||
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
|
||||
// Optimistic remove — drop the row immediately
|
||||
if (data?.advice) {
|
||||
data = { ...data, advice: data.advice.filter(a => a.ticker !== ticker) };
|
||||
}
|
||||
try {
|
||||
await removeHolding(ticker);
|
||||
fetchPortfolioData(false); // background: recalculate totals
|
||||
} catch (e) {
|
||||
loadError = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPortfolioData(showFullSpinner = false) {
|
||||
if (showFullSpinner) loading = true;
|
||||
else refreshing = true;
|
||||
loadError = null;
|
||||
fetch('/api/finance/portfolio')
|
||||
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
|
||||
.then(json => { data = json; })
|
||||
.catch(e => { loadError = (e as Error).message; })
|
||||
.finally(() => { loading = false; refreshing = false; });
|
||||
}
|
||||
let { data: _data } = $props();
|
||||
|
||||
let _booted = false;
|
||||
$effect(() => {
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
fetchPortfolioData(true); // initial load — show full spinner
|
||||
p.fetch(true);
|
||||
});
|
||||
|
||||
// ── Table sorting ────────────────────────────────────────────────────────────
|
||||
let sortCol = $state('ticker');
|
||||
let sortDir = $state(1); // 1 = asc, -1 = desc
|
||||
|
||||
function toggleSort(col: string): void {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
}
|
||||
|
||||
const sortedAdvice = $derived.by(() => {
|
||||
if (!data?.advice) return [];
|
||||
return [...data.advice].sort((a, b) => {
|
||||
let av, bv;
|
||||
switch (sortCol) {
|
||||
case 'ticker': av = a.ticker; bv = b.ticker; break;
|
||||
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
|
||||
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
|
||||
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
|
||||
case 'current': av = parseFloat(a.currentPrice) || 0; bv = parseFloat(b.currentPrice) || 0; break;
|
||||
case 'value': av = parseFloat(a.marketValue) || 0; bv = parseFloat(b.marketValue) || 0; break;
|
||||
case 'gl': av = parseFloat(a.gainLossPct) || 0; bv = parseFloat(b.gainLossPct) || 0; break;
|
||||
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
|
||||
default: return 0;
|
||||
}
|
||||
if (av < bv) return -sortDir;
|
||||
if (av > bv) return sortDir;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
const sortIcon = (col: string): string => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
|
||||
|
||||
|
||||
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
|
||||
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
|
||||
const totalGL = $derived(totalValue - totalCost);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
{#if loading}
|
||||
<div class="portfolio-page">
|
||||
{#if p.loading}
|
||||
<div class="loading-area">
|
||||
<Spinner size="lg" label="Loading portfolio…" />
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="error">{loadError}</div>
|
||||
{:else if p.loadError}
|
||||
<div class="error">{p.loadError}</div>
|
||||
|
||||
{:else if data?.advice}
|
||||
<!-- ── Toolbar ──────────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<button class="btn-add" onclick={openAdd}>
|
||||
{formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
{:else if p.data?.advice}
|
||||
<div class="portfolio-toolbar">
|
||||
<button class="btn-add" onclick={() => p.formOpen ? p.closeForm() : p.openForm()}>
|
||||
{p.formOpen ? '✕ Cancel' : '+ Add Holding'}
|
||||
</button>
|
||||
{#if refreshing}
|
||||
{#if p.refreshing}
|
||||
<span class="refreshing-hint">Updating prices…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Add Holding Form ─────────────────────────────────────── -->
|
||||
{#if formOpen}
|
||||
<div class="add-form">
|
||||
<div class="form-title">Add Holding</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="form-ticker">Ticker</label>
|
||||
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-shares">Shares</label>
|
||||
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-cost">Cost Basis / share</label>
|
||||
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-type">Type</label>
|
||||
<select id="form-type" bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
<option value="crypto">Crypto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="form-source">Source</label>
|
||||
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-save" onclick={submitHolding} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{#if formError}
|
||||
<div class="form-error">⚠ {formError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if p.formOpen}
|
||||
<AddHoldingForm saving={p.saving} error={p.formError} onSubmit={d => p.add(d)} onClose={() => p.closeForm()} />
|
||||
{/if}
|
||||
|
||||
{#if data.marketContext}
|
||||
<MarketContext ctx={data.marketContext} collapsible={true} />
|
||||
{#if p.data.marketContext}
|
||||
<MarketContext ctx={p.data.marketContext} collapsible={true} />
|
||||
{/if}
|
||||
|
||||
<!-- P&L Summary -->
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Value</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalValue)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total Cost</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue">{fmtShort(totalCost)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel-row">
|
||||
<span class="slabel">Total G/L</span>
|
||||
<span class="stip-wrap">
|
||||
<span class="stip-anchor">?</span>
|
||||
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="svalue {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdviceTable rows={p.data.advice} onUpdate={(t, d) => p.update(t, d)} onDelete={t => p.remove(t)} />
|
||||
|
||||
<!-- Holdings -->
|
||||
<section class="card-section">
|
||||
<h2>Holdings — Hold / Sell / Add Advice</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
|
||||
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
|
||||
<th>Advice</th><th>Reason</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedAdvice as a}
|
||||
{@const isEditing = inlineEdit?.ticker === a.ticker}
|
||||
<tr class:editing={isEditing}>
|
||||
<td class="ticker">{a.ticker}</td>
|
||||
<td>
|
||||
{#if isEditing}
|
||||
<select class="inline-select" bind:value={inlineEdit.type}>
|
||||
<option value="stock">stock</option>
|
||||
<option value="etf">etf</option>
|
||||
<option value="bond">bond</option>
|
||||
<option value="crypto">crypto</option>
|
||||
</select>
|
||||
{:else}
|
||||
<span class="tag">{a.type}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.shares} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{a.shares}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">
|
||||
{#if isEditing}
|
||||
<input class="inline-input" bind:value={inlineEdit.costBasis} type="number" min="0" step="any" />
|
||||
{:else}
|
||||
{fmt(a.costBasis)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{fmt(parseFloat(a.currentPrice))}</td>
|
||||
<td class="num">{fmt(parseFloat(a.marketValue))}</td>
|
||||
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray">—</span>{/if}</td>
|
||||
<td class={advClass(a.advice)}>{a.advice}</td>
|
||||
<td class="reason">{a.reason}</td>
|
||||
<td class="row-actions">
|
||||
{#if isEditing}
|
||||
<button class="btn-save-inline" onclick={saveInlineEdit} disabled={inlineSaving}>
|
||||
{inlineSaving ? '…' : '✓'}
|
||||
</button>
|
||||
<button class="btn-cancel-inline" onclick={() => inlineEdit = null}>✕</button>
|
||||
{:else}
|
||||
<button class="btn-edit" onclick={() => startInlineEdit(a)} title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick={() => deleteHolding(a.ticker)} title="Remove">✕</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Personal Finance (SimpleFIN) -->
|
||||
{#if data.personalFinance}
|
||||
{@const pf = data.personalFinance}
|
||||
<div class="summary-grid">
|
||||
<div class="scard">
|
||||
<div class="slabel">Net Worth</div>
|
||||
<div class="svalue {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Total Assets</div>
|
||||
<div class="svalue">{fmtShort(pf.totalAssets)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Liabilities</div>
|
||||
<div class="svalue red">{fmtShort(pf.totalLiabilities)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Cash ({pf.cashPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalCash)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Investments ({pf.investPct}%)</div>
|
||||
<div class="svalue">{fmtShort(pf.totalInvestments)}</div>
|
||||
</div>
|
||||
{#if pf.savingsRate != null}
|
||||
<div class="scard">
|
||||
<div class="slabel">Savings Rate</div>
|
||||
<div class="svalue {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Income</div>
|
||||
<div class="svalue">{fmtShort(pf.totalIncome)}</div>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="slabel">Monthly Spend</div>
|
||||
<div class="svalue">{fmtShort(pf.totalSpend)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<section class="card-section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.accounts as a}
|
||||
<tr>
|
||||
<td class="ticker">{a.name}</td>
|
||||
<td><span class="tag">{a.type}</span></td>
|
||||
<td class="gray">{a.org}</td>
|
||||
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card-section">
|
||||
<h2>Spending — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>
|
||||
{#each pf.categoryBreakdown.slice(0, 10) as c}
|
||||
<tr>
|
||||
<td>{c.category}</td>
|
||||
<td class="num right">{fmt(c.amount)}</td>
|
||||
<td class="num right gray">{c.pct}%</td>
|
||||
<td style="width:100px">
|
||||
<div class="bar-bg">
|
||||
<div class="bar-fill" style="width:{Math.min(c.pct,100)}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{#if p.data.personalFinance}
|
||||
<AccountsTable pf={p.data.personalFinance} />
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Page ── unique to this route ──────────────────────────────── */
|
||||
.page { max-width: 1400px; }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
||||
.toolbar { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
/* btn-add = primary button style (same as btn-primary) */
|
||||
.btn-add {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 9px 18px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-add:hover { background: var(--blue-darker); }
|
||||
|
||||
.refreshing-hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-dimmer);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
|
||||
/* ── Add holding form ────────────────────────────────────────────── */
|
||||
.add-form {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dimmer);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
.field label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dimmer);
|
||||
}
|
||||
|
||||
.field input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.field input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--fs-md);
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.field input:focus { border-color: var(--blue); }
|
||||
|
||||
.field select {
|
||||
background: var(--bg-card) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 10px center;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 32px 8px 12px;
|
||||
font-size: var(--fs-md);
|
||||
outline: none;
|
||||
min-width: 100px;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field select:focus { border-color: var(--blue); }
|
||||
|
||||
.btn-save {
|
||||
background: var(--blue-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 20px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--blue-darker); }
|
||||
.btn-save:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.form-error { color: var(--red); font-size: 12px; margin-top: 10px; }
|
||||
|
||||
/* ── Inline row editing ──────────────────────────────────────────── */
|
||||
tr.editing { background: var(--blue-badge); }
|
||||
|
||||
.inline-input {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: 12px;
|
||||
width: 80px;
|
||||
outline: none;
|
||||
}
|
||||
.inline-input:focus { border-color: var(--blue); }
|
||||
|
||||
.inline-select {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-xs);
|
||||
color: var(--text-secondary);
|
||||
padding: 3px 6px;
|
||||
font-size: var(--fs-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-save-inline {
|
||||
background: #14532d55;
|
||||
border: none;
|
||||
color: var(--green);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-save-inline:hover:not(:disabled) { background: #14532d99; }
|
||||
.btn-save-inline:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-inline {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-cancel-inline:hover { color: var(--text-muted); }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.btn-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--fs-md);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-edit:hover { color: var(--blue-muted); background: var(--blue-deep); }
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.btn-delete:hover { color: var(--red); background: var(--red-bg); }
|
||||
|
||||
/* ── Loading / error ─────────────────────────────────────────────── */
|
||||
.loading-area { display: flex; justify-content: center; align-items: center; padding: 100px 0; }
|
||||
.error { color: var(--red); background: var(--red-bg); border-radius: var(--radius-md); padding: 10px var(--space-lg); }
|
||||
|
||||
/* ── P&L summary grid ────────────────────────────────────────────── */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.scard { background: var(--bg-card); border-radius: var(--radius-md); padding: 12px var(--space-lg); }
|
||||
|
||||
.slabel-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.slabel { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.svalue { font-size: var(--fs-xl); font-weight: 700; color: var(--text-primary); margin-top: 4px; }
|
||||
|
||||
/* ── Summary card tooltips ───────────────────────────────────────── */
|
||||
.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
|
||||
|
||||
.stip-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--text-faint);
|
||||
color: var(--text-dimmer);
|
||||
font-size: var(--fs-2xs);
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.stip-box {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--text-faint);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
.stip-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--text-faint);
|
||||
}
|
||||
.stip-wrap:hover .stip-box { display: block; }
|
||||
|
||||
/* ── Card section (portfolio tables) ────────────────────────────── */
|
||||
.card-section {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-section h2 {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* Portfolio table overrides (full-width, tighter padding) */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dimmer);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid var(--bg-row-alt); }
|
||||
tbody tr:hover { background: #1e293b55; }
|
||||
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
|
||||
|
||||
th.sortable { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
th.sortable:hover { color: var(--text-muted); }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 14px; color: var(--text-primary); }
|
||||
.num { font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||||
.reason { color: var(--text-muted); font-size: var(--fs-sm); white-space: normal; max-width: 260px; }
|
||||
.right { text-align: right; }
|
||||
|
||||
/* Signal colour classes (used via glClass / advClass helpers) */
|
||||
.green { color: var(--green); font-weight: 600; }
|
||||
.yellow { color: var(--yellow); font-weight: 600; }
|
||||
.orange { color: var(--orange); font-weight: 600; }
|
||||
.red { color: var(--red); font-weight: 600; }
|
||||
.gray { color: var(--text-dim); }
|
||||
|
||||
.bar-bg { background: var(--bg-card); border-radius: var(--radius-xs); height: 6px; }
|
||||
.bar-fill { background: var(--blue); border-radius: var(--radius-xs); height: 6px; }
|
||||
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user