Add search and RAG workspace flows
This commit is contained in:
@@ -179,16 +179,16 @@ export default function PortfolioPage() {
|
||||
title="Portfolio"
|
||||
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => void queueRefresh()}>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
|
||||
<RefreshCcw className="size-4" />
|
||||
Queue price refresh
|
||||
</Button>
|
||||
<Button onClick={() => void queueInsights()}>
|
||||
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
|
||||
<BrainCircuit className="size-4" />
|
||||
Generate AI brief
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
@@ -219,7 +219,7 @@ export default function PortfolioPage() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : allocationData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
|
||||
@@ -255,7 +255,7 @@ export default function PortfolioPage() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : performanceData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={performanceData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
@@ -302,106 +302,214 @@ export default function PortfolioPage() {
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
||||
) : (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<table className="data-table min-w-[1020px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Company</th>
|
||||
<th>Shares</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Gain/Loss</th>
|
||||
<th>Research</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.id}>
|
||||
<td>{holding.ticker}</td>
|
||||
<td>{holding.company_name ?? 'n/a'}</td>
|
||||
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
||||
<td>{formatCurrency(holding.avg_cost)}</td>
|
||||
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
||||
<td>{formatCurrency(holding.market_value)}</td>
|
||||
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
|
||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{holdings.map((holding) => (
|
||||
<article key={holding.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{holding.ticker}</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{holding.company_name ?? 'Company name unavailable'}</p>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
{formatCurrency(holding.gain_loss)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Shares</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asNumber(holding.shares).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Avg Cost</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.avg_cost)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Price</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Value</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.market_value)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className={`mt-3 text-xs ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
Return {formatPercent(holding.gain_loss_pct)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden max-w-full overflow-x-auto lg:block">
|
||||
<table className="data-table min-w-[1020px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Company</th>
|
||||
<th>Shares</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Gain/Loss</th>
|
||||
<th>Research</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.id}>
|
||||
<td>{holding.ticker}</td>
|
||||
<td>{holding.company_name ?? 'n/a'}</td>
|
||||
<td>{asNumber(holding.shares).toLocaleString()}</td>
|
||||
<td>{formatCurrency(holding.avg_cost)}</td>
|
||||
<td>{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</td>
|
||||
<td>{formatCurrency(holding.market_value)}</td>
|
||||
<td className={asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}>
|
||||
{formatCurrency(holding.gain_loss)} ({formatPercent(holding.gain_loss_pct)})
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -440,12 +548,12 @@ export default function PortfolioPage() {
|
||||
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
<Button type="submit" className="w-full sm:flex-1">
|
||||
<Plus className="size-4" />
|
||||
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||
</Button>
|
||||
{editingHoldingId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user