feat: add hover shadows, smooth transitions for collapsible sections, and active border accent
This commit is contained in:
@@ -86,7 +86,9 @@ export function ChartLabPanel() {
|
||||
|
||||
Promise.all([loadCandles, loadIndicators]).then(([cData, iData]) => {
|
||||
if (!cancelled) {
|
||||
setCandles(cData ?? []);
|
||||
// Defensive: ensure we never set candles to a non-array value.
|
||||
// The API can return {result:{data:null}} which parse() returns as null.
|
||||
setCandles(Array.isArray(cData) ? cData : []);
|
||||
setIndicators(iData);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -113,47 +115,44 @@ export function ChartLabPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
if (candles.length === 0) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
<p className="text-sm text-[#5a5b6a] py-8">{UI_STRINGS.noCandleData}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Build chart data by merging candles with indicator overlays.
|
||||
const rsiSeries = indicators?.rsi ?? [];
|
||||
const relvolSeries = indicators?.relvol ?? [];
|
||||
const emaMap: Record<number, Map<string, number>> = {};
|
||||
if (indicators?.ema) {
|
||||
for (const [period, entries] of Object.entries(indicators.ema)) {
|
||||
if (!entries) continue;
|
||||
const m = new Map<string, number>();
|
||||
for (const e of entries) m.set(e.ts, e.value);
|
||||
for (const e of entries) {
|
||||
if (!e || !e.ts || e.value === undefined) continue;
|
||||
m.set(e.ts, e.value);
|
||||
}
|
||||
emaMap[Number(period)] = m;
|
||||
}
|
||||
}
|
||||
|
||||
const chartData: ChartDataPoint[] = candles.map((c) => {
|
||||
const d: ChartDataPoint = {
|
||||
date: formatCandleDate(c.ts),
|
||||
ts: c.ts,
|
||||
o: c.o,
|
||||
h: c.h,
|
||||
l: c.l,
|
||||
c: c.c,
|
||||
v: c.v,
|
||||
};
|
||||
const rsiEntry = rsiSeries.find((r) => r.ts === c.ts);
|
||||
if (rsiEntry) d.rsi = rsiEntry.value;
|
||||
const rvEntry = relvolSeries.find((r) => r.ts === c.ts);
|
||||
if (rvEntry) d.relvol = rvEntry.value;
|
||||
for (const period of [9, 21, 50, 200]) {
|
||||
const emaVal = emaMap[period]?.get(c.ts);
|
||||
if (emaVal !== undefined) d[`ema${period}` as keyof ChartDataPoint] = emaVal;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
const chartData: ChartDataPoint[] = candles
|
||||
.filter((c): c is PriceCandle => c != null && typeof c === 'object' && 'ts' in c)
|
||||
.map((c) => {
|
||||
const d: ChartDataPoint = {
|
||||
date: formatCandleDate(c.ts),
|
||||
ts: c.ts,
|
||||
o: c.o,
|
||||
h: c.h,
|
||||
l: c.l,
|
||||
c: c.c,
|
||||
v: c.v,
|
||||
};
|
||||
const rsiEntry = rsiSeries.find((r) => r?.ts === c.ts);
|
||||
if (rsiEntry && rsiEntry.value !== undefined) d.rsi = rsiEntry.value;
|
||||
const rvEntry = relvolSeries.find((r) => r?.ts === c.ts);
|
||||
if (rvEntry && rvEntry.value !== undefined) d.relvol = rvEntry.value;
|
||||
for (const period of [9, 21, 50, 200]) {
|
||||
const emaVal = emaMap[period]?.get(c.ts);
|
||||
if (emaVal !== undefined) d[`ema${period}` as keyof ChartDataPoint] = emaVal;
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
const upColor = DAY_COLORS.up;
|
||||
const downColor = DAY_COLORS.down;
|
||||
@@ -196,7 +195,7 @@ export function ChartLabPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-lg font-semibold text-[#e6e7ec]">{UI_STRINGS.chartLabTitle}</h2>
|
||||
|
||||
{/* Price chart with EMA overlays */}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCollapsibleContext } from "@/lib/collapsible-context";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
/** Optional override: when Escape is pressed, call this instead of context collapse */
|
||||
onEscapeClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable collapsible panel wrapper. Used for Filings and Options DD panels
|
||||
* on the main dashboard — they start collapsed and expand on demand.
|
||||
* Smooth height transition via max-height animation.
|
||||
*
|
||||
* Supports Escape-to-close via a shared context so LayoutShell can collapse
|
||||
* the open section when the user presses Escape.
|
||||
*/
|
||||
export function CollapsibleSection({ title, children, defaultOpen = false, onEscapeClose }: CollapsibleSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
// Register with the shared context so LayoutShell knows which section is open.
|
||||
const { openSectionId, setOpenSectionId } = useCollapsibleContext();
|
||||
const sectionId = `section-${title.replace(/\s+/g, "-").toLowerCase()}`;
|
||||
|
||||
// When Escape is pressed and this section is open, collapse it.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEscapeClose) {
|
||||
onEscapeClose();
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
// Clear the shared open section so another panel can take over
|
||||
if (openSectionId === sectionId) {
|
||||
setOpenSectionId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler, true); // capture phase so it fires even if child swallows
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handler, true);
|
||||
};
|
||||
}, [isOpen, onEscapeClose, sectionId, openSectionId, setOpenSectionId]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const next = !isOpen;
|
||||
setIsOpen(next);
|
||||
if (next) {
|
||||
setOpenSectionId(sectionId);
|
||||
} else {
|
||||
setOpenSectionId(null);
|
||||
}
|
||||
}, [isOpen, sectionId, setOpenSectionId]);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318]" aria-label={title}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`${title} — ${isOpen ? "collapsed" : "expanded"}. Press Enter to toggle, Escape to close.`}
|
||||
className="w-full flex items-center justify-between px-5 py-3 rounded-t-xl hover:bg-[#1a1b24] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff] focus:ring-offset-2 focus:ring-offset-[#0a0b0f]"
|
||||
tabIndex={0}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-[#e6e7ec]">{title}</h2>
|
||||
<span className={`text-[10px] text-[#5a5b6a] transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`} aria-hidden="true">
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Content with smooth height transition */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
isOpen ? "max-h-[9999px] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
role="region"
|
||||
aria-label={`${title} content`}
|
||||
>
|
||||
<div className="px-5 pb-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
"use client";
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||
import { api, type EdgarFiling, type FilerMeta, type Form13fResult, type Form4Result } from "@/lib/trpc";
|
||||
|
||||
// M6 Filings Panel (Slice 6) — Educational SEC EDGAR filings viewer.
|
||||
// Read-only display of filing data from SEC EDGAR. No trade actions (ADR-0007).
|
||||
// Frame: "filings are public records that can be reviewed for informational purposes."
|
||||
|
||||
type SortField = "form" | "dateReporter" | "accessionNumber" | "reportDate";
|
||||
type SortDir = "asc" | "desc";
|
||||
type DetailView = "none" | "13f" | "form4";
|
||||
|
||||
const FORM_TYPE_COLORS: Record<string, string> = {
|
||||
"10-K": "bg-[#064e3b] text-[#6ee7b7] border border-[#065f46]",
|
||||
"10-Q": "bg-[#1e3a5f] text-[#93c5fd] border border-[#1d4ed8]",
|
||||
"8-K": "bg-[#78350f] text-[#fcd34d] border border-[#92400e]",
|
||||
"13F-HR": "bg-[#4c1d95] text-[#c4b5fd] border border-[#5b21b6]",
|
||||
"SC 13G": "bg-[#831843] text-[#f9a8d4] border border-[#9d174d]",
|
||||
"SC 13D": "bg-[#831843] text-[#f9a8d4] border border-[#9d174d]",
|
||||
"DEF 14A": "bg-[#36531f] text-[#a3e635] border border-[#4d7c0f]",
|
||||
"N-1A": "bg-[#36531f] text-[#a3e635] border border-[#4d7c0f]",
|
||||
};
|
||||
|
||||
function getFormColor(form: string): string {
|
||||
return FORM_TYPE_COLORS[form] ?? "bg-[#2a2b3a] text-[#e6e7ec] border border-[#3f3f46]";
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatShares(shares: number): string {
|
||||
if (shares >= 1e6) return `${(shares / 1e6).toFixed(2)}M`;
|
||||
if (shares >= 1e3) return `${(shares / 1e3).toFixed(2)}K`;
|
||||
return shares.toLocaleString();
|
||||
}
|
||||
|
||||
function accessionLink(accession: string): string {
|
||||
const normalized = accession.replace(/-/g, "");
|
||||
return `https://www.sec.gov/Archives/edgar/data/${normalized}`;
|
||||
}
|
||||
|
||||
// Materiality tags for 8-K items (educational reference).
|
||||
const MATERIALITY_ITEMS: Record<string, string> = {
|
||||
"1A": "Risk factors update",
|
||||
"2A": "Unregistered sales of equity securities",
|
||||
"3A": "Bankruptcy or receivership",
|
||||
"5": "Changes in registrant's certifying accountant",
|
||||
"6A": "Purchase, sale, or disposal of material assets",
|
||||
"7A": "Regulatory acquisition or disposal of business",
|
||||
"8A": "Changes in control of registrant",
|
||||
"9A": "Financial statements and exhibits",
|
||||
};
|
||||
|
||||
function MaterialityBadge(_itemNumber: string): JSX.Element | null {
|
||||
// Materiality tags are informational; actual 8-K item numbers come from
|
||||
// the filing body, not the index. The badge renders if an item number is provided.
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FilingsPanelProps {
|
||||
initialCik?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard navigation state for the filings table.
|
||||
* Tracks the currently focused cell so arrow keys can move between cells.
|
||||
*/
|
||||
interface FocusPosition {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export function FilingsPanel({ initialCik }: FilingsPanelProps) {
|
||||
const [cikInput, setCikInput] = useState(initialCik ?? "");
|
||||
const [formFilter, setFormFilter] = useState<string>("all");
|
||||
const [sortField, setSortField] = useState<SortField>("dateReporter");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
||||
const [dateRange, setDateRange] = useState<{ from?: string; to?: string }>({});
|
||||
|
||||
// Plain async state (matching trpc.ts client API).
|
||||
const [filingsData, setFilingsData] = useState<{ filings: EdgarFiling[]; cik: string } | null>(null);
|
||||
const [filingsLoading, setFilingsLoading] = useState(false);
|
||||
const [filingsError, setFilingsError] = useState<string | null>(null);
|
||||
const [metaData, setMetaData] = useState<{ meta: FilerMeta; cik: string } | null>(null);
|
||||
const [metaLoading, setMetaLoading] = useState(false);
|
||||
const [summary, setSummary] = useState<string | null>(null);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
const [detailView, setDetailView] = useState<DetailView>("none");
|
||||
const [detailData, setDetailData] = useState<Form13fResult | Form4Result | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
// Table keyboard navigation state
|
||||
const [tableFocus, setTableFocus] = useState<FocusPosition | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadFilings = useCallback(async (cik: string, formTypes?: string[], dateRange?: { from?: string; to?: string }) => {
|
||||
if (!cik) { setFilingsData(null); setMetaData(null); return; }
|
||||
setFilingsLoading(true);
|
||||
setFilingsError(null);
|
||||
try {
|
||||
const result = await api.edgar.filings_index({ cik, formTypes, dateRange });
|
||||
setFilingsData(result);
|
||||
} catch (e) {
|
||||
setFilingsError(e instanceof Error ? e.message : "Failed to load filings");
|
||||
} finally {
|
||||
setFilingsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMeta = useCallback(async (cik: string) => {
|
||||
if (!cik) { setMetaData(null); return; }
|
||||
setMetaLoading(true);
|
||||
try {
|
||||
const result = await api.edgar.filer_cik_meta(cik);
|
||||
setMetaData(result);
|
||||
} catch {
|
||||
// Silently fail — meta is optional
|
||||
} finally {
|
||||
setMetaLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSummary = useCallback(async (cik: string) => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
const result = await api.edgar.filings_index({ cik });
|
||||
const filingText = result.filings.map((f) =>
|
||||
`${f.form} | Filed: ${f.dateReporter} | Report: ${f.reportDate} | Accession: ${f.accessionNumber}`
|
||||
).join("\n");
|
||||
const summaryText = `EDGAR filing summary for CIK ${cik}: ${result.filings.length} recent filing(s). ${filingText}\n\nEducational note: These are public SEC filings. Always consult original documents.`;
|
||||
setSummary(summaryText);
|
||||
} catch {
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (cik: string, accession: string, viewType: "13f" | "form4") => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
if (viewType === "13f") {
|
||||
const result = await api.edgar.form13f_holdings(cik, accession);
|
||||
setDetailData(result.holdings);
|
||||
} else {
|
||||
const result = await api.edgar.form4_tx(cik, accession);
|
||||
setDetailData(result.transactions);
|
||||
}
|
||||
} catch (e) {
|
||||
setDetailError(e instanceof Error ? e.message : "Failed to load detail");
|
||||
setDetailData(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filings = filingsData?.filings ?? [];
|
||||
|
||||
// Auto-load when CIK changes.
|
||||
useEffect(() => {
|
||||
loadFilings(cikInput, formFilter !== "all" ? [formFilter] : undefined, dateRange);
|
||||
loadMeta(cikInput);
|
||||
}, [cikInput, formFilter, dateRange.from, dateRange.to, loadFilings, loadMeta]);
|
||||
|
||||
const filteredFilings = useMemo(() => {
|
||||
let result = filings;
|
||||
if (formFilter !== "all") {
|
||||
result = result.filter((f) => f.form === formFilter);
|
||||
}
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === "form") {
|
||||
cmp = a.form.localeCompare(b.form);
|
||||
} else if (sortField === "dateReporter") {
|
||||
cmp = a.dateReporter.localeCompare(b.dateReporter);
|
||||
} else if (sortField === "accessionNumber") {
|
||||
cmp = a.accessionNumber.localeCompare(b.accessionNumber);
|
||||
} else if (sortField === "reportDate") {
|
||||
cmp = a.reportDate.localeCompare(b.reportDate);
|
||||
}
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [filings, formFilter, sortField, sortDir]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueFormTypes = useMemo(() => {
|
||||
const forms = new Set<string>();
|
||||
for (const f of filings) forms.add(f.form);
|
||||
return Array.from(forms).sort();
|
||||
}, [filings]);
|
||||
|
||||
const handleSearch = () => {
|
||||
loadFilings(cikInput, formFilter !== "all" ? [formFilter] : undefined, dateRange);
|
||||
loadMeta(cikInput);
|
||||
};
|
||||
|
||||
const handleViewDetail = (accession: string, formType: string) => {
|
||||
if (formType === "13F-HR") {
|
||||
setDetailView("13f");
|
||||
loadDetail(cikInput, accession, "13f");
|
||||
} else if (formType === "Form 4" || formType === "4") {
|
||||
setDetailView("form4");
|
||||
loadDetail(cikInput, accession, "form4");
|
||||
} else {
|
||||
setDetailView("none");
|
||||
setDetailData(null);
|
||||
setDetailError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setDetailView("none");
|
||||
setDetailData(null);
|
||||
setDetailError(null);
|
||||
};
|
||||
|
||||
// --- Table keyboard navigation ---
|
||||
const NUM_COLS = 5; // Form, Date, Accession, Report Date, Actions
|
||||
|
||||
const handleTableKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!tableFocus) return;
|
||||
|
||||
let { row, col } = tableFocus;
|
||||
const numRows = filteredFilings.length;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (row > 0) setTableFocus({ row: row - 1, col });
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (row < numRows - 1) setTableFocus({ row: row + 1, col });
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (col > 0) setTableFocus({ row, col: col - 1 });
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (col < NUM_COLS - 1) setTableFocus({ row, col: col + 1 });
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
// Focus the first actionable cell in the row (Actions column = last col)
|
||||
setTableFocus({ row, col: NUM_COLS - 1 });
|
||||
break;
|
||||
}
|
||||
}, [tableFocus, filteredFilings.length]);
|
||||
|
||||
const handleCellClick = (row: number, col: number) => {
|
||||
setTableFocus({ row, col });
|
||||
};
|
||||
|
||||
if (filingsError) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec] mb-4">SEC Filings</h2>
|
||||
<div className="p-4 bg-[#1e1e2e] rounded-lg border border-[#f87171]/30">
|
||||
<p className="text-[#f87171] text-sm">
|
||||
Unable to load filings. Please verify the CIK number and try again.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec]">SEC Filings Panel</h2>
|
||||
|
||||
{/* CIK Input */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">CIK Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cikInput}
|
||||
onChange={(e) => setCikInput(e.target.value.replace(/\D/g, "").slice(0, 10))}
|
||||
placeholder="Enter CIK (e.g., 320193 for AAPL)"
|
||||
aria-label="CIK number input"
|
||||
className="w-full px-3 py-2 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-sm focus:outline-none focus:border-[#6366f1]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!cikInput}
|
||||
aria-label="Load filings"
|
||||
className="px-4 py-2 bg-[#6366f1] text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#4f46e5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
|
||||
>
|
||||
Load Filings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filer Metadata Sidebar */}
|
||||
{metaData?.meta && (
|
||||
<div className="p-3 bg-[#1e1e2e] rounded-lg border border-[#3f3f46]">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-[#8a8b9a]">Filer:</span>{" "}
|
||||
<span className="text-[#e6e7ec] font-medium">{metaData.meta.name ?? "Unknown"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8a8b9a]">CIK:</span>{" "}
|
||||
<span className="text-[#e6e7ec] font-mono">{metaData.meta.cik}</span>
|
||||
</div>
|
||||
{metaData.meta.sic && (
|
||||
<div>
|
||||
<span className="text-[#8a8b9a]">SIC:</span>{" "}
|
||||
<span className="text-[#e6e7ec] font-mono">{metaData.meta.sic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{(filingsLoading || metaLoading) && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#6366f1]"></div>
|
||||
<span className="ml-3 text-[#8a8b9a] text-sm">Loading filings...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!filingsLoading && filings.length === 0 && cikInput && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[#8a8b9a] text-sm">
|
||||
No filings found for CIK {cikInput}. This may indicate the CIK is invalid or has no recent filings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filings Table with keyboard navigation */}
|
||||
{filteredFilings.length > 0 && (
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="overflow-x-auto"
|
||||
onKeyDown={handleTableKeyDown}
|
||||
tabIndex={0}
|
||||
role="grid"
|
||||
aria-label="SEC filings table. Use arrow keys to navigate between cells."
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#3f3f46]">
|
||||
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("form")} tabIndex={0} aria-label="Sort by Form" role="columnheader">
|
||||
Form {sortField === "form" && (sortDir === "asc" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("dateReporter")} tabIndex={0} aria-label="Sort by Filing Date" role="columnheader">
|
||||
Filing Date {sortField === "dateReporter" && (sortDir === "asc" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium" role="columnheader">Accession No.</th>
|
||||
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium cursor-pointer hover:text-[#e6e7ec]" onClick={() => handleSort("reportDate")} tabIndex={0} aria-label="Sort by Report Date" role="columnheader">
|
||||
Report Date {sortField === "reportDate" && (sortDir === "asc" ? "↑" : "↓")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-[#8a8b9a] font-medium" role="columnheader">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFilings.map((filing, rowIdx) => (
|
||||
<tr key={filing.accessionNumber} className="border-b border-[#2a2b3a] hover:bg-[#1e1e2e]" role="row">
|
||||
{/* Form cell */}
|
||||
<td
|
||||
className="py-2 px-3"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 0 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 0)}
|
||||
aria-label={`Form ${filing.form}, filing date ${filing.dateReporter}`}
|
||||
>
|
||||
<span className={`inline-flex px-2 py-1 rounded text-xs font-medium ${getFormColor(filing.form)}`}>
|
||||
{filing.form}
|
||||
</span>
|
||||
</td>
|
||||
{/* Filing Date cell */}
|
||||
<td
|
||||
className="py-2 px-3 text-[#e6e7ec]"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 1 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 1)}
|
||||
aria-label={`Filing date: ${filing.dateReporter}`}
|
||||
>
|
||||
{filing.dateReporter}
|
||||
</td>
|
||||
{/* Accession No. cell */}
|
||||
<td
|
||||
className="py-2 px-3 font-mono text-xs text-[#8a8b9a]"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 2 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 2)}
|
||||
aria-label={`Accession number: ${filing.accessionNumber}`}
|
||||
>
|
||||
{filing.accessionNumber}
|
||||
</td>
|
||||
{/* Report Date cell */}
|
||||
<td
|
||||
className="py-2 px-3 text-[#e6e7ec]"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 3 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 3)}
|
||||
aria-label={`Report date: ${filing.reportDate}`}
|
||||
>
|
||||
{filing.reportDate}
|
||||
</td>
|
||||
{/* Actions cell */}
|
||||
<td
|
||||
className="py-2 px-3"
|
||||
tabIndex={tableFocus?.row === rowIdx && tableFocus?.col === 4 ? 0 : -1}
|
||||
onClick={() => handleCellClick(rowIdx, 4)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={accessionLink(filing.accessionNumber)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#6366f1] hover:text-[#818cf8] text-xs underline"
|
||||
aria-label={`View filing on SEC EDGAR for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
View
|
||||
</a>
|
||||
{(filing.form === "13F-HR" || filing.form === "Form 4" || filing.form === "4") && (
|
||||
<button
|
||||
onClick={() => handleViewDetail(filing.accessionNumber, filing.form)}
|
||||
className="text-[#a78bfa] hover:text-[#c4b5fd] text-xs underline"
|
||||
aria-label={`View details for accession ${filing.accessionNumber}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Type Filter */}
|
||||
{uniqueFormTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Filter by form type">
|
||||
<button onClick={() => setFormFilter("all")} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === "all" ? "bg-[#6366f1] text-white border-[#6366f1]" : "bg-transparent text-[#8a8b9a] border-[#3f3f46] hover:border-[#6366f1]"}`} aria-label={`Show all filings (${filings.length})`}>
|
||||
All ({filings.length})
|
||||
</button>
|
||||
{uniqueFormTypes.map((form) => (
|
||||
<button key={form} onClick={() => setFormFilter(form)} className={`px-3 py-1 rounded-full text-xs font-medium border ${formFilter === form ? "bg-[#6366f1] text-white border-[#6366f1]" : getFormColor(form)}`} aria-label={`Filter by ${form}, ${filings.filter((f) => f.form === form).length} filings`}>
|
||||
{form} ({filings.filter((f) => f.form === form).length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">From Date</label>
|
||||
<input type="date" value={dateRange.from ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter from date" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[#8a8b9a] mb-1">To Date</label>
|
||||
<input type="date" value={dateRange.to ?? ""} onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value || undefined }))} className="px-3 py-1.5 bg-[#1e1e2e] border border-[#3f3f46] rounded-lg text-[#e6e7ec] text-xs focus:outline-none focus:border-[#6366f1]" aria-label="Filter to date" />
|
||||
</div>
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<button onClick={() => setDateRange({})} className="px-3 py-1.5 text-xs text-[#8a8b9a] hover:text-[#e6e7ec]" aria-label="Clear date range filter">Clear</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summarize Button */}
|
||||
{filings.length > 0 && (
|
||||
<button
|
||||
onClick={() => loadSummary(cikInput)}
|
||||
disabled={summaryLoading}
|
||||
className="px-4 py-2 bg-[#064e3b] text-[#6ee7b7] rounded-lg text-sm font-medium border border-[#065f46] disabled:opacity-50 hover:bg-[#065f46] transition-colors focus:outline-none focus:ring-2 focus:ring-[#4f8cff]"
|
||||
aria-label={summaryLoading ? "Summarizing filings" : "Summarize filings"}
|
||||
>
|
||||
{summaryLoading ? "Summarizing..." : "Summarize"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Summary Display */}
|
||||
{summary && (
|
||||
<div className="p-3 bg-[#064e3b]/20 rounded-lg border border-[#065f46]" role="status" aria-label="Filing summary">
|
||||
<p className="text-xs text-[#6ee7b7] whitespace-pre-wrap font-mono">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail View: 13F Holdings */}
|
||||
{detailView === "13f" && (
|
||||
<div className="p-4 bg-[#4c1d95]/10 rounded-lg border border-[#5b21b6]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-[#c4b5fd]">13F-HR Holdings</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close 13F holdings detail">Close</button>
|
||||
</div>
|
||||
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading holdings...</p>}
|
||||
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
|
||||
{detailData && "holdings" in detailData && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs" role="table" aria-label="13F holdings table">
|
||||
<thead>
|
||||
<tr className="border-b border-[#5b21b6] text-[#8a8b9a]">
|
||||
<th className="text-left py-1 px-2" scope="col">CUSIP</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Issuer</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Value ($)</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Shares</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((detailData as Form13fResult).holdings ?? []).map((h, i) => (
|
||||
<tr key={i} className="border-b border-[#5b21b6]/30">
|
||||
<td className="py-1 px-2 font-mono text-[#c4b5fd]">{h.cusip}</td>
|
||||
<td className="py-1 px-2 text-[#e6e7ec]">{h.issuerName}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatCurrency(h.value)}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#c4b5fd]">{formatShares(h.sshPrnamt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail View: Form 4 Transactions */}
|
||||
{detailView === "form4" && (
|
||||
<div className="p-4 bg-[#78350f]/10 rounded-lg border border-[#92400e]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-bold text-[#fcd34d]">Form 4 Transactions</h3>
|
||||
<button onClick={handleCloseDetail} className="text-[#8a8b9a] hover:text-[#e6e7ec] text-xs" aria-label="Close Form 4 transactions detail">Close</button>
|
||||
</div>
|
||||
{detailLoading && <p className="text-[#8a8b9a] text-xs">Loading transactions...</p>}
|
||||
{detailError && <p className="text-[#f87171] text-xs">{detailError}</p>}
|
||||
{detailData && "transactions" in detailData && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs" role="table" aria-label="Form 4 transactions table">
|
||||
<thead>
|
||||
<tr className="border-b border-[#92400e] text-[#8a8b9a]">
|
||||
<th className="text-left py-1 px-2" scope="col">Reporter</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Relationship</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Security</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Date</th>
|
||||
<th className="text-left py-1 px-2" scope="col">Code</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Shares</th>
|
||||
<th className="text-right py-1 px-2" scope="col">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{((detailData as Form4Result).transactions ?? []).map((tx, i) => (
|
||||
<tr key={i} className="border-b border-[#92400e]/30">
|
||||
<td className="py-1 px-2 text-[#e6e7ec]">{tx.reporter}</td>
|
||||
<td className="py-1 px-2 text-[#8a8b9a]">{tx.relationship}</td>
|
||||
<td className="py-1 px-2 text-[#e6e7ec]">{tx.securityTitle}</td>
|
||||
<td className="py-1 px-2 text-[#8a8b9a]">{tx.transactionDate}</td>
|
||||
<td className="py-1 px-2 font-mono text-[#fcd34d]">{tx.transactionCode}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#e6e7ec]">{tx.shares.toLocaleString()}</td>
|
||||
<td className="py-1 px-2 text-right font-mono text-[#e6e7ec]">${tx.price.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Educational Disclaimer */}
|
||||
<div className="p-3 bg-[#1e1e2e] rounded-lg border border-[#3f3f46]" role="note">
|
||||
<p className="text-xs text-[#8a8b9a]">
|
||||
<strong className="text-[#e6e7ec]">Educational Note:</strong> SEC filings are public records
|
||||
available through the EDGAR database. This panel displays filing metadata for informational
|
||||
purposes. Always consult the original filing documents for complete information.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
import { useState, useMemo } from "react";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
// Local type alias matching server-side DashboardRollupRow (ADR-0007: neutral labels only).
|
||||
type ConvictionDelta = 'increasing' | 'reducing' | 'flat' | 'mixed';
|
||||
interface DashboardRollupRow {
|
||||
symbol: string;
|
||||
convictionDelta: ConvictionDelta;
|
||||
insiderRecencyDays: number | null;
|
||||
classRollFlag: boolean;
|
||||
alert: null;
|
||||
}
|
||||
|
||||
// M4 Institutional Dashboard Panel (Slice 8).
|
||||
// Read-only grid: Symbol | Net Active Conviction Δ | insider recency | class-roll flag | alert.
|
||||
// Sortable + filterable. No trade actions (ADR-0007).
|
||||
// Frame: "capital appears to be moving" — never "follow this flow".
|
||||
|
||||
type SortField = 'symbol' | 'convictionDelta' | 'insiderRecencyDays' | 'classRollFlag';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
const CONViction_WEIGHT: Record<string, number> = {
|
||||
mixed: 3,
|
||||
increasing: 2,
|
||||
reducing: 2,
|
||||
flat: 1,
|
||||
};
|
||||
|
||||
const DELTA_BADGE: Record<string, { label: string; color: string }> = {
|
||||
mixed: { label: 'Mixed', color: 'text-[#fcd34d] border-[#78350f]' },
|
||||
increasing: { label: 'Increasing', color: 'text-[#34d399] border-[#064e3b]' },
|
||||
reducing: { label: 'Reducing', color: 'text-[#f87171] border-[#7f1d1d]' },
|
||||
flat: { label: 'Flat', color: 'text-[#8a8b9a] border-[#2a2b3a]' },
|
||||
};
|
||||
|
||||
export function InstitutionalDashboard() {
|
||||
const [sortField, setSortField] = useState<SortField>('convictionDelta');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
const { data: rollupRows, isLoading, error } = api.dashboard.rollup.useQuery();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!rollupRows) return [];
|
||||
let filtered = rollupRows;
|
||||
if (filter !== 'all') {
|
||||
filtered = rollupRows.filter((r) => r.convictionDelta === filter);
|
||||
}
|
||||
return [...filtered].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortField === 'symbol') {
|
||||
cmp = a.symbol.localeCompare(b.symbol);
|
||||
} else if (sortField === 'convictionDelta') {
|
||||
cmp = (CONViction_WEIGHT[b.convictionDelta] || 0) - (CONViction_WEIGHT[a.convictionDelta] || 0);
|
||||
} else if (sortField === 'insiderRecencyDays') {
|
||||
if (a.insiderRecencyDays === null && b.insiderRecencyDays === null) cmp = 0;
|
||||
else if (a.insiderRecencyDays === null) cmp = 1;
|
||||
else if (b.insiderRecencyDays === null) cmp = -1;
|
||||
else cmp = a.insiderRecencyDays - b.insiderRecencyDays;
|
||||
} else if (sortField === 'classRollFlag') {
|
||||
cmp = (a.classRollFlag ? 1 : 0) - (b.classRollFlag ? 1 : 0);
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [rollupRows, sortField, sortDir, filter]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<header className="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-bold text-[#e6e7ec]">Institutional Dashboard</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[#8a8b9a]">Filter:</label>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs bg-[#1e1f2a] border border-[#2a2b3a] text-[#e6e7ec] rounded px-2 py-1"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
<option value="increasing">Increasing</option>
|
||||
<option value="reducing">Reducing</option>
|
||||
<option value="flat">Flat</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">Loading rollup...</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-[#ef4444] py-8">Error loading rollup data.</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a] py-8">No symbols in watchlist or portfolio.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2b3a] text-[#8a8b9a]">
|
||||
<th className="text-left py-2 px-3">Symbol</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('convictionDelta')}>
|
||||
Conviction Δ {sortField === 'convictionDelta' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('insiderRecencyDays')}>
|
||||
Insider Recency {sortField === 'insiderRecencyDays' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 cursor-pointer" onClick={() => handleSort('classRollFlag')}>
|
||||
Class Roll {sortField === 'classRollFlag' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3">Alert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.symbol} className="border-b border-[#1e1f2a] hover:bg-[#1a1b24] transition-colors">
|
||||
<td className="py-2 px-3 font-mono text-[#e6e7ec]">{row.symbol}</td>
|
||||
<td className="py-2 px-3">
|
||||
{(() => {
|
||||
const badge = DELTA_BADGE[row.convictionDelta];
|
||||
return (
|
||||
<span className={`text-xs uppercase tracking-wider border rounded px-2 py-0.5 ${badge.color}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-[#8a8b9a]">
|
||||
{row.insiderRecencyDays !== null ? `${row.insiderRecencyDays}d` : '—'}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{row.classRollFlag ? (
|
||||
<span className="text-[#fcd34d] text-xs">Yes</span>
|
||||
) : (
|
||||
<span className="text-[#5a5b6a]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{/* AlertEngine stub (slice 17) — wire later. */}
|
||||
<span className="text-[#5a5b6a] text-xs">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM summary — rendered server-side via tRPC query */}
|
||||
{rollupRows && rollupRows.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-[#1a1b24] border border-[#2a2b3a] rounded">
|
||||
<p className="text-xs text-[#8a8b9a]">
|
||||
Summary will be provided via the rollup query.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
/**
|
||||
* Options Due Diligence Panel (M3) — DESIGN.md §4 + Slice 15.
|
||||
*
|
||||
* Educational, read-only panel that teaches the convex mechanic of options.
|
||||
* ADR-0007: no trade verbs (buy/sell/cut/trim). Defined-risk spreads get a green
|
||||
* "defined-risk" stamp; undefined-risk spreads are shaded "advanced only."
|
||||
*
|
||||
* Displays:
|
||||
* - IV rank/percentile bar (teaches the mechanic, NOT a hype gauge)
|
||||
* - Greeks display (Delta, Gamma, Theta, Vega)
|
||||
* - OI walls visualization (open interest by strike)
|
||||
* - Max-pain chart
|
||||
* - Payoff diagram teaching the convex mechanic
|
||||
* - Defined-risk stamp for spreads; "advanced only" warning for undefined
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { api, type Quote, type PriceCandle } from "@/lib/trpc";
|
||||
import {
|
||||
computeIVRank,
|
||||
classifyRisk,
|
||||
computeOptionPayoff,
|
||||
generatePayoffCurve,
|
||||
extractOIWalls,
|
||||
computeMaxPain,
|
||||
type IVRankResult,
|
||||
type PayoffPoint,
|
||||
type OIWall,
|
||||
} from "@/lib/options-pure";
|
||||
import type { OptionChainRow, OptionGreeks } from "@/types/options";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Use IVRankResult from options-pure (has currentIV, rank, percentile)
|
||||
type IVRankData = IVRankResult;
|
||||
|
||||
// ─── UI Components ──────────────────────────────────────────────────────────
|
||||
|
||||
/** IV Rank/Percentile bar — teaches the mechanic, NOT a hype gauge. */
|
||||
function IVRankBar({ data }: { data: IVRankData | null }) {
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Implied Volatility Rank</h3>
|
||||
<div className="h-4 bg-[#1e1f2e] rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#3a3b4a] w-full" />
|
||||
</div>
|
||||
<p className="text-xs text-[#5a5b6a]">No IV data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getColor = (rank: number) => {
|
||||
if (rank < 25) return "bg-[#3b82f6]"; // low IV — good for buyers
|
||||
if (rank < 50) return "bg-[#22c55e]"; // moderate
|
||||
if (rank < 75) return "bg-[#eab308]"; // elevated
|
||||
return "bg-[#ef4444]"; // high IV — expensive
|
||||
};
|
||||
|
||||
const getLabel = (rank: number) => {
|
||||
if (rank < 25) return "Low IV — typically cheaper options";
|
||||
if (rank < 50) return "Below-median IV";
|
||||
if (rank < 75) return "Above-median IV";
|
||||
return "High IV — typically expensive options";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Implied Volatility Rank</h3>
|
||||
<div className="flex justify-between text-xs text-[#8a8b9a]">
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
<div className="h-4 bg-[#1e1f2e] rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full ${getColor(data.rank)} transition-all duration-500`}
|
||||
style={{ width: `${data.rank}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 h-4 w-0.5 bg-white opacity-60"
|
||||
style={{ left: `${data.rank}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#8a8b9a]">
|
||||
Rank: {data.rank} | Current IV: {(data.currentIV * 100).toFixed(1)}%
|
||||
(52w: {(data.iv52wLow * 100).toFixed(1)}%–{(data.iv52wHigh * 100).toFixed(1)}%)
|
||||
</p>
|
||||
<p className="text-xs text-[#5a5b6a] italic">{getLabel(data.rank)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Greeks display card. */
|
||||
function GreeksDisplay({ greeks }: { greeks: OptionGreeks | null }) {
|
||||
if (!greeks) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Greeks</h3>
|
||||
<p className="text-xs text-[#5a5b6a]">No greeks data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GreekRow = ({ label, value, description }: { label: string; value: number | null; description: string }) => (
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[#e6e7ec]">{label}</span>
|
||||
<span className="text-xs text-[#5a5b6a] ml-2">{description}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-mono ${value === null ? "text-[#5a5b6a]" : "text-[#e6e7ec]"}`}>
|
||||
{value === null ? "—" : value.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Greeks</h3>
|
||||
<GreekRow label="Delta" value={greeks.delta} description="Sensitivity to stock price" />
|
||||
<GreekRow label="Gamma" value={greeks.gamma} description="Rate of change of delta" />
|
||||
<GreekRow label="Theta" value={greeks.theta} description="Time decay per day" />
|
||||
<GreekRow label="Vega" value={greeks.vega} description="Sensitivity to IV (per 1%)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** OI Walls visualization — simple bar chart of open interest by strike. */
|
||||
function OIWalls({ walls }: { walls: OIWall[] }) {
|
||||
if (walls.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Open Interest Walls</h3>
|
||||
<p className="text-xs text-[#5a5b6a]">No OI data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxOI = Math.max(...walls.map((w) => w.openInterest));
|
||||
const calls = walls.filter((w) => w.right === "call");
|
||||
const puts = walls.filter((w) => w.right === "put");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Open Interest Walls</h3>
|
||||
<div className="flex gap-1 items-end" style={{ height: 120 }}>
|
||||
{walls.map((wall, i) => (
|
||||
<div key={`${wall.right}-${wall.strike}-${i}`} className="flex-1 flex flex-col items-center">
|
||||
<span className="text-[10px] text-[#8a8b9a] mb-1">
|
||||
{(wall.openInterest / 1000).toFixed(0)}k
|
||||
</span>
|
||||
<div
|
||||
className={`w-full rounded-t ${wall.right === "call" ? "bg-[#3b82f6]" : "bg-[#ef4444]"}`}
|
||||
style={{ height: `${(wall.openInterest / maxOI) * 80}px`, minHeight: 2 }}
|
||||
/>
|
||||
<span className="text-[10px] text-[#5a5b6a] mt-1">${wall.strike}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-[#3b82f6]">● Calls</span>
|
||||
<span className="text-[#ef4444]">● Puts</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Max Pain indicator. */
|
||||
function MaxPainDisplay({ rows }: { rows: OptionChainRow[] }) {
|
||||
const maxPain = useMemo(() => computeMaxPain(rows), [rows]);
|
||||
|
||||
if (maxPain === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Max Pain</h3>
|
||||
<p className="text-xs text-[#5a5b6a]">Insufficient data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Max Pain</h3>
|
||||
<p className="text-2xl font-bold text-[#eab308]">${maxPain}</p>
|
||||
<p className="text-xs text-[#5a5b6a]">
|
||||
The strike where options expire with maximum total value (theory)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Payoff diagram — teaches the convex mechanic via visual. */
|
||||
function PayoffDiagram({ rows, currentStockPrice }: { rows: OptionChainRow[]; currentStockPrice: number }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Payoff Diagram</h3>
|
||||
<p className="text-xs text-[#5a5b6a]">No chain data to render</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick the at-the-money or nearest strike for the payoff diagram
|
||||
const atmRow = rows.reduce((best, r) =>
|
||||
Math.abs(r.strike - currentStockPrice) < Math.abs(best.strike - currentStockPrice) ? r : best
|
||||
, rows[0]);
|
||||
|
||||
const payoffPoints = generatePayoffCurve(
|
||||
[atmRow.strike],
|
||||
currentStockPrice,
|
||||
atmRow.right,
|
||||
atmRow.lastPrice ?? 0,
|
||||
50,
|
||||
);
|
||||
|
||||
const minPnl = Math.min(...payoffPoints.map((p) => p.pnl));
|
||||
const maxPnl = Math.max(...payoffPoints.map((p) => p.pnl));
|
||||
const pnlRange = maxPnl - minPnl || 1;
|
||||
|
||||
// SVG viewBox: 400x200
|
||||
const svgWidth = 400;
|
||||
const svgHeight = 200;
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 60 };
|
||||
const chartW = svgWidth - padding.left - padding.right;
|
||||
const chartH = svgHeight - padding.top - padding.bottom;
|
||||
|
||||
const xScale = (price: number) => {
|
||||
const minPrice = Math.min(...payoffPoints.map((p) => p.stockPrice));
|
||||
const maxPrice = Math.max(...payoffPoints.map((p) => p.stockPrice));
|
||||
return padding.left + ((price - minPrice) / (maxPrice - minPrice || 1)) * chartW;
|
||||
};
|
||||
|
||||
const yScale = (pnl: number) => {
|
||||
const zeroY = padding.top + ((maxPnl - 0) / pnlRange) * chartH;
|
||||
return padding.top + ((maxPnl - pnl) / pnlRange) * chartH;
|
||||
};
|
||||
|
||||
// Build path for the payoff line
|
||||
const pathD = payoffPoints.map((p, i) => `${i === 0 ? "M" : "L"}${xScale(p.stockPrice)},${yScale(p.pnl)}`).join(" ");
|
||||
|
||||
// Zero PnL line
|
||||
const zeroY = yScale(0);
|
||||
|
||||
// Current stock price line
|
||||
const currentX = xScale(currentStockPrice);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[#e6e7ec]">Payoff Diagram</h3>
|
||||
<svg viewBox={`0 0 ${svgWidth} ${svgHeight}`} className="w-full h-auto bg-[#0a0b0f] rounded border border-[#2a2b3a]">
|
||||
{/* Grid lines */}
|
||||
<line x1={padding.left} y1={zeroY} x2={svgWidth - padding.right} y2={zeroY}
|
||||
stroke="#3a3b4a" strokeWidth="1" strokeDasharray="4" />
|
||||
<line x1={currentX} y1={padding.top} x2={currentX} y2={svgHeight - padding.bottom}
|
||||
stroke="#5a5b6a" strokeWidth="1" strokeDasharray="2" />
|
||||
|
||||
{/* Payoff line */}
|
||||
<path d={pathD} fill="none" stroke={atmRow.right === "call" ? "#3b82f6" : "#ef4444"} strokeWidth="2" />
|
||||
|
||||
{/* Zero PnL label */}
|
||||
<text x={padding.left + 5} y={zeroY - 5} fill="#5a5b6a" fontSize="10">PnL $0</text>
|
||||
|
||||
{/* Current stock price label */}
|
||||
<text x={currentX + 5} y={padding.top + 12} fill="#8a8b9a" fontSize="10">
|
||||
${currentStockPrice.toFixed(0)}
|
||||
</text>
|
||||
|
||||
{/* Strike label */}
|
||||
<text x={xScale(atmRow.strike) - 15} y={svgHeight - 5} fill="#8a8b9a" fontSize="10">
|
||||
K=${atmRow.strike}
|
||||
</text>
|
||||
|
||||
{/* Teaching label */}
|
||||
<text x={svgWidth / 2 - 60} y={svgHeight - 5} fill="#5a5b6a" fontSize="10">
|
||||
Stock price at expiration →
|
||||
</text>
|
||||
</svg>
|
||||
<p className="text-xs text-[#5a5b6a] italic">
|
||||
Shows P&L at expiration for a {atmRow.right} option with strike ${atmRow.strike}.
|
||||
The curve illustrates the convex payoff mechanic — gains accelerate as the stock moves
|
||||
in your favor past the strike.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Defined-risk stamp for spreads. */
|
||||
function RiskStamp({ isSpread }: { isSpread: boolean }) {
|
||||
if (isSpread) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#052e1a] border border-[#166534]">
|
||||
<span className="text-green-400 text-lg">✓</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-green-400">Defined-Risk Spread</p>
|
||||
<p className="text-xs text-green-300/70">Max loss is known upfront</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[#4c1d1d] border border-[#991b1b]">
|
||||
<span className="text-red-400 text-lg">⚠</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-400">Advanced Only</p>
|
||||
<p className="text-xs text-red-300/70">Undefined risk — potential for significant losses</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Panel Component ───────────────────────────────────────────────────
|
||||
|
||||
export function OptionsDDPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [chainData, setChainData] = useState<{ symbol: string; expiration: string | null; rows: OptionChainRow[] } | null>(null);
|
||||
const [greeksData, setGreeksData] = useState<{ symbol: string; greeks: OptionGreeks | null; strike?: number; right?: "call" | "put"; lastPrice?: number } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch chain data
|
||||
api.options.chain(symbol)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setChainData(data);
|
||||
// Also fetch greeks for the first row with greeks
|
||||
const firstWithGreeks = data.rows.find((r) => r.greeks);
|
||||
if (firstWithGreeks && firstWithGreeks.strike) {
|
||||
api.options.greeks(symbol, { expiry: data.expiration ?? undefined, strike: firstWithGreeks.strike })
|
||||
.then((gData) => {
|
||||
if (!cancelled) setGreeksData(gData);
|
||||
})
|
||||
.catch(() => { /* ignore greeks fetch error */ });
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : "request failed");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [symbol]);
|
||||
|
||||
const walls = useMemo(() => chainData ? extractOIWalls(chainData.rows) : [], [chainData]);
|
||||
const maxPain = useMemo(() => chainData ? computeMaxPain(chainData.rows) : 0, [chainData]);
|
||||
|
||||
// Compute IV rank from chain data (using implied volatilities)
|
||||
const ivRankData = useMemo((): IVRankData | null => {
|
||||
if (!chainData || chainData.rows.length === 0) return null;
|
||||
const ivs = chainData.rows
|
||||
.map((r) => r.impliedVolatility)
|
||||
.filter((v): v is number => v !== null && v !== undefined);
|
||||
if (ivs.length === 0) return null;
|
||||
|
||||
const currentIV = ivs[ivs.length - 1]; // use last (nearest expiry) as proxy
|
||||
const iv52wHigh = Math.max(...ivs);
|
||||
const iv52wLow = Math.min(...ivs);
|
||||
return computeIVRank(currentIV, iv52wHigh, iv52wLow);
|
||||
}, [chainData]);
|
||||
|
||||
const atmRow = useMemo(() => {
|
||||
if (!chainData || chainData.rows.length === 0) return null;
|
||||
// Find the row closest to current price (use first row's strike as proxy)
|
||||
return chainData.rows.reduce((best, r) =>
|
||||
Math.abs(r.strike - (chainData.rows[0]?.strike ?? 0)) <
|
||||
Math.abs(best.strike - (chainData.rows[0]?.strike ?? 0)) ? r : best
|
||||
, chainData.rows[0]);
|
||||
}, [chainData]);
|
||||
|
||||
const isSpread = !!greeksData?.greeks;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<div className="flex items-center gap-2 text-[#5a5b6a] text-sm">
|
||||
<div className="w-4 h-4 border-2 border-[#5a5b6a] border-t-transparent rounded-full animate-spin" />
|
||||
Loading options data...
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<p className="text-sm text-red-400">Error: {error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chainData || chainData.rows.length === 0) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
<p className="text-sm text-[#5a5b6a]">No options data available for {symbol}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-6 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">Options Due Diligence</h2>
|
||||
{chainData.expiration && (
|
||||
<span className="text-xs text-[#8a8b9a] bg-[#1e1f2e] px-2 py-1 rounded">
|
||||
Exp: {chainData.expiration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk Stamp */}
|
||||
<RiskStamp isSpread={isSpread} />
|
||||
|
||||
{/* IV Rank Bar */}
|
||||
<IVRankBar data={ivRankData} />
|
||||
|
||||
{/* Greeks Display */}
|
||||
<GreeksDisplay greeks={greeksData?.greeks ?? null} />
|
||||
|
||||
{/* OI Walls */}
|
||||
<OIWalls walls={walls} />
|
||||
|
||||
{/* Max Pain */}
|
||||
<MaxPainDisplay rows={chainData.rows} />
|
||||
|
||||
{/* Payoff Diagram */}
|
||||
<PayoffDiagram rows={chainData.rows} currentStockPrice={atmRow?.strike ?? 0} />
|
||||
|
||||
{/* Educational footer */}
|
||||
<div className="pt-4 border-t border-[#2a2b3a]">
|
||||
<p className="text-xs text-[#5a5b6a]">
|
||||
This panel is educational. It does not recommend any specific trade. Options involve
|
||||
risk and are not suitable for all investors. Past performance does not guarantee future results.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
|
||||
|
||||
// M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the
|
||||
// stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change
|
||||
// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007).
|
||||
// uses shape (▲/▼) + color + a text label — never color alone (G3). No imperative verbs (ADR-0007). No
|
||||
// directional language. Educational display only.
|
||||
export function OverviewPanel() {
|
||||
const symbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [snap, setSnap] = useState<Snapshot | null>(null);
|
||||
@@ -64,7 +65,7 @@ export function OverviewPanel() {
|
||||
const spark = (candles ?? []).map((c, i) => ({ i, c: c.c }));
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5">
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<header className="flex items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-[#e6e7ec]">{symbol}</h2>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
export function PortfolioPanel() {
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [draftSymbol, setDraftSymbol] = useState("");
|
||||
const [draftShares, setDraftShares] = useState("");
|
||||
const [draftCost, setDraftCost] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setHoldings(await api.portfolio.holdings());
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const add = async () => {
|
||||
if (!draftSymbol.trim() || !draftShares || !draftCost) return;
|
||||
await api.portfolio.addHolding(
|
||||
draftSymbol.trim().toUpperCase(),
|
||||
parseFloat(draftShares),
|
||||
parseFloat(draftCost),
|
||||
);
|
||||
setDraftSymbol(""); setDraftShares(""); setDraftCost("");
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (symbol: string) => {
|
||||
await api.portfolio.removeHolding(symbol);
|
||||
load();
|
||||
};
|
||||
|
||||
const totalValue = holdings.reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.portfolioTitle || "Portfolio"}</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input value={draftSymbol} onChange={(e) => setDraftSymbol(e.target.value)}
|
||||
placeholder="Symbol" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" />
|
||||
<input value={draftShares} onChange={(e) => setDraftShares(e.target.value)}
|
||||
placeholder="Shares" type="number" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" />
|
||||
<input value={draftCost} onChange={(e) => setDraftCost(e.target.value)}
|
||||
placeholder="Avg cost" type="number" step="0.01" className="bg-[#0a0b0f] border border-[#2a2b3a] rounded-lg px-2 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" />
|
||||
</div>
|
||||
<button onClick={add}
|
||||
className="w-full px-3 py-1.5 rounded-lg text-sm bg-[#4f8cff] text-white">Add Holding</button>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#5a5b6a]">Loading...</p>
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyPortfolio || "No holdings yet."}</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
<div className="flex justify-between text-xs text-[#5a5b6a] pb-1 border-b border-[#2a2b3a]">
|
||||
<span>Symbol</span>
|
||||
<span>Shares</span>
|
||||
<span>Avg Cost</span>
|
||||
<span>Value</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{holdings.map((h) => (
|
||||
<div key={h.symbol} className="flex items-center justify-between py-1 text-sm rounded hover:bg-[#1a1b24] transition-colors">
|
||||
<span className="font-mono w-16 text-[#e6e7ec]">{h.symbol}</span>
|
||||
<span className="w-16 text-right text-[#8a8b9a]">{h.shares}</span>
|
||||
<span className="w-20 text-right text-[#8a8b9a]">${h.avg_cost.toFixed(2)}</span>
|
||||
<span className="w-20 text-right text-[#e6e7ec]">${(h.shares * h.avg_cost).toFixed(0)}</span>
|
||||
<button onClick={() => remove(h.symbol)}
|
||||
className="text-xs text-[#5a5b6a] hover:text-[#ef4444]">Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between pt-2 border-t border-[#2a2b3a] text-sm font-semibold text-[#e6e7ec]">
|
||||
<span>Total</span>
|
||||
<span>${totalValue.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type WatchlistEntry } from "@/lib/trpc";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
export function WatchlistPanel() {
|
||||
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeSymbol, setActiveSymbol] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setEntries(await api.watchlists.list());
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const add = async () => {
|
||||
if (!draft.trim()) return;
|
||||
const sym = draft.trim().toUpperCase();
|
||||
await api.watchlists.addSymbol(sym);
|
||||
setDraft("");
|
||||
load();
|
||||
};
|
||||
|
||||
const remove = async (symbol: string) => {
|
||||
await api.watchlists.removeSymbol(symbol);
|
||||
load();
|
||||
};
|
||||
|
||||
const isActive = (symbol: string) => activeSymbol === symbol;
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-[#2a2b3a] bg-[#121318] p-5 space-y-4 hover:shadow-[0_2px_8px_rgba(79,140,255,0.1)] transition-shadow duration-300">
|
||||
<h2 className="text-base font-semibold text-[#e6e7ec]">{UI_STRINGS.watchlistTitle || "Watchlist"}</h2>
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-2">
|
||||
<input value={draft} onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="Add symbol..."
|
||||
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-3 py-1.5 text-sm text-[#e6e7ec] placeholder:text-[#5a5b6a]" />
|
||||
<button type="submit" className="px-3 py-1.5 rounded text-sm bg-[#4f8cff] text-white">Add</button>
|
||||
</form>
|
||||
{loading ? (
|
||||
<p className="text-sm text-[#5a5b6a]">Loading...</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="text-sm text-[#5a5b6a]">{UI_STRINGS.emptyWatchlist || "Your watchlist is empty."}</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{entries.map((e) => (
|
||||
<div key={e.symbol}
|
||||
className={`flex items-center justify-between py-1 px-2 rounded transition-colors ${
|
||||
isActive(e.symbol)
|
||||
? "bg-[#1a1b24] border-l-2 border-l-[#4f8cff]"
|
||||
: "hover:bg-[#1a1b24]"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-sm text-[#e6e7ec]">{e.symbol}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setActiveSymbol(isActive(e.symbol) ? null : e.symbol)}
|
||||
className="text-xs text-[#5a5b6a] hover:text-[#4f8cff]"
|
||||
>
|
||||
{isActive(e.symbol) ? "Deselect" : "View"}
|
||||
</button>
|
||||
<button onClick={() => remove(e.symbol)}
|
||||
className="text-xs text-[#5a5b6a] hover:text-[#ef4444]">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-[#5a5b6a] italic">{UI_STRINGS.primaryRuleFooter}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user