flatten app to repo root and update docker deployment for single-stack runtime
This commit is contained in:
332
app/portfolio/page.tsx
Normal file
332
app/portfolio/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, CartesianGrid, XAxis, YAxis, Bar } from 'recharts';
|
||||
import { BrainCircuit, Plus, RefreshCcw, 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 { StatusPill } from '@/components/ui/status-pill';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useTaskPoller } from '@/hooks/use-task-poller';
|
||||
import {
|
||||
deleteHolding,
|
||||
getLatestPortfolioInsight,
|
||||
getTask,
|
||||
getPortfolioSummary,
|
||||
listHoldings,
|
||||
queuePortfolioInsights,
|
||||
queuePriceRefresh,
|
||||
upsertHolding
|
||||
} from '@/lib/api';
|
||||
import type { Holding, PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
|
||||
import { asNumber, formatCurrency, formatPercent } from '@/lib/format';
|
||||
|
||||
type FormState = {
|
||||
ticker: string;
|
||||
shares: string;
|
||||
avgCost: string;
|
||||
currentPrice: string;
|
||||
};
|
||||
|
||||
const CHART_COLORS = ['#6effd8', '#5fd3ff', '#66ffa1', '#8dbbff', '#f4f88f', '#ff9c9c'];
|
||||
|
||||
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 [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 [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
|
||||
const loadPortfolio = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [holdingsRes, summaryRes, insightRes] = await Promise.all([
|
||||
listHoldings(),
|
||||
getPortfolioSummary(),
|
||||
getLatestPortfolioInsight()
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadPortfolio();
|
||||
}
|
||||
}, [isPending, isAuthenticated, loadPortfolio]);
|
||||
|
||||
const polledTask = useTaskPoller({
|
||||
taskId: activeTask?.id ?? null,
|
||||
onTerminalState: async () => {
|
||||
setActiveTask(null);
|
||||
await loadPortfolio();
|
||||
}
|
||||
});
|
||||
|
||||
const liveTask = polledTask ?? activeTask;
|
||||
|
||||
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 submitHolding = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await upsertHolding({
|
||||
ticker: form.ticker.toUpperCase(),
|
||||
shares: Number(form.shares),
|
||||
avgCost: Number(form.avgCost),
|
||||
currentPrice: form.currentPrice ? Number(form.currentPrice) : undefined
|
||||
});
|
||||
|
||||
setForm({ ticker: '', shares: '', avgCost: '', currentPrice: '' });
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save holding');
|
||||
}
|
||||
};
|
||||
|
||||
const queueRefresh = async () => {
|
||||
try {
|
||||
const { task } = await queuePriceRefresh();
|
||||
const latest = await getTask(task.id);
|
||||
setActiveTask(latest.task);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to queue price refresh');
|
||||
}
|
||||
};
|
||||
|
||||
const queueInsights = async () => {
|
||||
try {
|
||||
const { task } = await queuePortfolioInsights();
|
||||
const latest = await getTask(task.id);
|
||||
setActiveTask(latest.task);
|
||||
} 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 Matrix"
|
||||
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => void queueRefresh()}>
|
||||
<RefreshCcw className="size-4" />
|
||||
Queue price refresh
|
||||
</Button>
|
||||
<Button onClick={() => void queueInsights()}>
|
||||
<BrainCircuit className="size-4" />
|
||||
Generate AI brief
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{liveTask ? (
|
||||
<Panel title="Task Runner" subtitle={liveTask.id}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">{liveTask.task_type}</p>
|
||||
<StatusPill status={liveTask.status} />
|
||||
</div>
|
||||
{liveTask.error ? <p className="mt-2 text-sm text-[#ff9f9f]">{liveTask.error}</p> : null}
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{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-[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)} />
|
||||
</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-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={performanceData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke="rgba(126, 217, 255, 0.2)" />
|
||||
<XAxis dataKey="name" stroke="#8cb6c5" fontSize={12} />
|
||||
<YAxis stroke="#8cb6c5" fontSize={12} />
|
||||
<Tooltip formatter={(value: number | string | undefined) => `${asNumber(value).toFixed(2)}%`} />
|
||||
<Bar dataKey="value" fill="#68ffd5" 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="overflow-x-auto">
|
||||
<table className="data-table min-w-[780px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticker</th>
|
||||
<th>Shares</th>
|
||||
<th>Avg Cost</th>
|
||||
<th>Price</th>
|
||||
<th>Value</th>
|
||||
<th>Gain/Loss</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{holdings.map((holding) => (
|
||||
<tr key={holding.id}>
|
||||
<td>{holding.ticker}</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>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
await loadPortfolio();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Add / Update 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} 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)]">Shares</label>
|
||||
<Input 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 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 type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
<Plus className="size-4" />
|
||||
Save holding
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user