574 lines
27 KiB
TypeScript
574 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import Link from 'next/link';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
|
|
import { BrainCircuit, Plus, RefreshCcw, SquarePen, Trash2 } from 'lucide-react';
|
|
import { AppShell } from '@/components/shell/app-shell';
|
|
import { Panel } from '@/components/ui/panel';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
|
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
|
import {
|
|
deleteHolding,
|
|
queuePortfolioInsights,
|
|
queuePriceRefresh,
|
|
updateHolding,
|
|
upsertHolding
|
|
} from '@/lib/api';
|
|
import type { Holding, PortfolioInsight, PortfolioSummary } from '@/lib/types';
|
|
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
|
|
import { queryKeys } from '@/lib/query/keys';
|
|
import {
|
|
holdingsQueryOptions,
|
|
latestPortfolioInsightQueryOptions,
|
|
portfolioSummaryQueryOptions
|
|
} from '@/lib/query/options';
|
|
|
|
type FormState = {
|
|
ticker: string;
|
|
companyName: string;
|
|
shares: string;
|
|
avgCost: string;
|
|
currentPrice: string;
|
|
};
|
|
|
|
const CHART_COLORS = ['#d9dee5', '#c7cdd5', '#b5bcc5', '#a4acb6', '#939ca7', '#828b97'];
|
|
const CHART_TEXT = '#f3f5f7';
|
|
const CHART_MUTED = '#a1a9b3';
|
|
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
|
|
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
|
|
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
|
|
|
|
const EMPTY_SUMMARY: PortfolioSummary = {
|
|
positions: 0,
|
|
total_value: '0',
|
|
total_gain_loss: '0',
|
|
total_cost_basis: '0',
|
|
avg_return_pct: '0'
|
|
};
|
|
|
|
export default function PortfolioPage() {
|
|
const { isPending, isAuthenticated } = useAuthGuard();
|
|
const queryClient = useQueryClient();
|
|
const { prefetchResearchTicker } = useLinkPrefetch();
|
|
|
|
const [holdings, setHoldings] = useState<Holding[]>([]);
|
|
const [summary, setSummary] = useState<PortfolioSummary>(EMPTY_SUMMARY);
|
|
const [latestInsight, setLatestInsight] = useState<PortfolioInsight | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [editingHoldingId, setEditingHoldingId] = useState<number | null>(null);
|
|
const [form, setForm] = useState<FormState>({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
|
|
|
const loadPortfolio = useCallback(async () => {
|
|
const holdingsOptions = holdingsQueryOptions();
|
|
const summaryOptions = portfolioSummaryQueryOptions();
|
|
const insightOptions = latestPortfolioInsightQueryOptions();
|
|
|
|
if (!queryClient.getQueryData(summaryOptions.queryKey)) {
|
|
setLoading(true);
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
|
queryClient.fetchQuery(holdingsOptions),
|
|
queryClient.fetchQuery(summaryOptions),
|
|
queryClient.fetchQuery(insightOptions)
|
|
]);
|
|
|
|
setHoldings(holdingsRes.holdings);
|
|
setSummary(summaryRes.summary);
|
|
setLatestInsight(insightRes.insight);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Could not fetch portfolio data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [queryClient]);
|
|
|
|
useEffect(() => {
|
|
if (!isPending && isAuthenticated) {
|
|
void loadPortfolio();
|
|
}
|
|
}, [isPending, isAuthenticated, loadPortfolio]);
|
|
|
|
const allocationData = useMemo(
|
|
() => holdings.map((holding) => ({
|
|
name: holding.ticker,
|
|
value: asNumber(holding.market_value)
|
|
})),
|
|
[holdings]
|
|
);
|
|
|
|
const performanceData = useMemo(
|
|
() => holdings.map((holding) => ({
|
|
name: holding.ticker,
|
|
value: asNumber(holding.gain_loss_pct)
|
|
})),
|
|
[holdings]
|
|
);
|
|
|
|
const resetHoldingForm = useCallback(() => {
|
|
setEditingHoldingId(null);
|
|
setForm({ ticker: '', companyName: '', shares: '', avgCost: '', currentPrice: '' });
|
|
}, []);
|
|
|
|
const submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
try {
|
|
if (editingHoldingId === null) {
|
|
await upsertHolding({
|
|
ticker: form.ticker.toUpperCase(),
|
|
companyName: form.companyName.trim() || undefined,
|
|
shares: Number(form.shares),
|
|
avgCost: Number(form.avgCost),
|
|
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
|
});
|
|
} else {
|
|
await updateHolding(editingHoldingId, {
|
|
companyName: form.companyName.trim() || undefined,
|
|
shares: Number(form.shares),
|
|
avgCost: Number(form.avgCost),
|
|
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
|
});
|
|
}
|
|
|
|
resetHoldingForm();
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
|
await loadPortfolio();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save holding');
|
|
}
|
|
};
|
|
|
|
const queueRefresh = async () => {
|
|
try {
|
|
await queuePriceRefresh();
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
|
await loadPortfolio();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
|
|
}
|
|
};
|
|
|
|
const queueInsights = async () => {
|
|
try {
|
|
await queuePortfolioInsights();
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.recentTasks(20) });
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.latestPortfolioInsight() });
|
|
await loadPortfolio();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to queue portfolio insights');
|
|
}
|
|
};
|
|
|
|
if (isPending || !isAuthenticated) {
|
|
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading portfolio matrix...</div>;
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
title="Portfolio"
|
|
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
|
actions={(
|
|
<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 className="w-full sm:w-auto" onClick={() => void queueInsights()}>
|
|
<BrainCircuit className="size-4" />
|
|
Generate AI brief
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
{error ? (
|
|
<Panel>
|
|
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
|
</Panel>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<Panel title="Total Value" className="lg:col-span-1">
|
|
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{formatCurrency(summary.total_value)}</p>
|
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Cost basis {formatCurrency(summary.total_cost_basis)}</p>
|
|
</Panel>
|
|
<Panel title="Unrealized P&L" className="lg:col-span-1">
|
|
<p className={`text-3xl font-semibold ${asNumber(summary.total_gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
|
|
{formatCurrency(summary.total_gain_loss)}
|
|
</p>
|
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Average return {formatPercent(summary.avg_return_pct)}</p>
|
|
</Panel>
|
|
<Panel title="Positions" className="lg:col-span-1">
|
|
<p className="text-3xl font-semibold text-[color:var(--terminal-bright)]">{summary.positions}</p>
|
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Active symbols in portfolio.</p>
|
|
</Panel>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
|
<Panel title="Allocation">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
|
) : allocationData.length > 0 ? (
|
|
<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}>
|
|
{allocationData.map((entry, index) => (
|
|
<Cell key={`${entry.name}-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
formatter={(value: number | string | undefined) => formatCurrency(value)}
|
|
contentStyle={{
|
|
backgroundColor: CHART_TOOLTIP_BG,
|
|
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
|
borderRadius: '0.75rem'
|
|
}}
|
|
labelStyle={{ color: CHART_TEXT }}
|
|
itemStyle={{ color: CHART_TEXT }}
|
|
/>
|
|
<Legend
|
|
verticalAlign="bottom"
|
|
iconType="circle"
|
|
wrapperStyle={{ paddingTop: '0.75rem' }}
|
|
formatter={(value) => <span style={{ color: CHART_TEXT }}>{value}</span>}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings yet.</p>
|
|
)}
|
|
</Panel>
|
|
|
|
<Panel title="Performance %">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
|
) : performanceData.length > 0 ? (
|
|
<div className="h-[260px] sm:h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={performanceData}>
|
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
|
<XAxis
|
|
dataKey="name"
|
|
stroke={CHART_MUTED}
|
|
fontSize={12}
|
|
axisLine={{ stroke: CHART_MUTED }}
|
|
tickLine={{ stroke: CHART_MUTED }}
|
|
tick={{ fill: CHART_MUTED }}
|
|
/>
|
|
<YAxis
|
|
stroke={CHART_MUTED}
|
|
fontSize={12}
|
|
axisLine={{ stroke: CHART_MUTED }}
|
|
tickLine={{ stroke: CHART_MUTED }}
|
|
tick={{ fill: CHART_MUTED }}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`}
|
|
contentStyle={{
|
|
backgroundColor: CHART_TOOLTIP_BG,
|
|
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
|
borderRadius: '0.75rem'
|
|
}}
|
|
labelStyle={{ color: CHART_TEXT }}
|
|
itemStyle={{ color: CHART_TEXT }}
|
|
cursor={{ fill: 'rgba(220, 226, 234, 0.08)' }}
|
|
/>
|
|
<Bar dataKey="value" fill="#d9dee5" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No performance data yet.</p>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.5fr_1fr]">
|
|
<Panel title="Holdings Table" subtitle="Live mark-to-market values from latest refresh.">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading holdings...</p>
|
|
) : holdings.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
|
) : (
|
|
<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>
|
|
</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>
|
|
|
|
<Panel title={editingHoldingId === null ? 'Add Holding' : 'Edit Holding'}>
|
|
<form onSubmit={submitHolding} className="space-y-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Ticker</label>
|
|
<Input
|
|
value={form.ticker}
|
|
aria-label="Holding ticker"
|
|
disabled={editingHoldingId !== null}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, ticker: event.target.value.toUpperCase() }))}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Company Name (optional)</label>
|
|
<Input
|
|
value={form.companyName}
|
|
aria-label="Holding company name"
|
|
onChange={(event) => setForm((prev) => ({ ...prev, companyName: event.target.value }))}
|
|
placeholder="Resolved from coverage or filings if left blank"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Shares</label>
|
|
<Input aria-label="Holding shares" type="number" step="0.0001" min="0.0001" value={form.shares} onChange={(event) => setForm((prev) => ({ ...prev, shares: event.target.value }))} required />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Average Cost</label>
|
|
<Input aria-label="Holding average cost" type="number" step="0.0001" min="0.0001" value={form.avgCost} onChange={(event) => setForm((prev) => ({ ...prev, avgCost: event.target.value }))} required />
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Current Price (optional)</label>
|
|
<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="w-full sm:flex-1">
|
|
<Plus className="size-4" />
|
|
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
|
</Button>
|
|
{editingHoldingId !== null ? (
|
|
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
|
|
Cancel
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
|
|
<div className="mt-5 border-t border-[color:var(--line-weak)] pt-4">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-[color:var(--terminal-muted)]">Latest AI Insight</p>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">
|
|
{latestInsight?.content ?? 'No insight available yet. Queue an AI brief from the header.'}
|
|
</p>
|
|
</div>
|
|
</Panel>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|