diff options
Diffstat (limited to 'frontend/components')
| -rw-r--r-- | frontend/components/prism/FinancialsCard.tsx | 136 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsPage.tsx | 73 | ||||
| -rw-r--r-- | frontend/components/prism/Sidebar.tsx | 5 |
3 files changed, 213 insertions, 1 deletions
diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx new file mode 100644 index 0000000..94a6618 --- /dev/null +++ b/frontend/components/prism/FinancialsCard.tsx @@ -0,0 +1,136 @@ +"use client"; +import type { FinancialRow, FinancialsResponse } from "@/types/api"; +import { fmtLarge } from "@/lib/format"; + +type StatementKey = "income" | "balance" | "cash_flow"; +type PeriodKey = "annual" | "quarterly"; + +type Props = { + data: FinancialsResponse; + statement: StatementKey; + period: PeriodKey; + onChangeStatement: (s: StatementKey) => void; + onChangePeriod: (p: PeriodKey) => void; +}; + +const STMT_LABELS: Record<StatementKey, string> = { + income: "INCOME", + balance: "BALANCE", + cash_flow: "CASH FLOW", +}; + +function fmtFinVal(val: number | null | undefined, isMargin: boolean): string { + if (val === null || val === undefined) return "—"; + if (isMargin) return `${(val * 100).toFixed(1)}%`; + return fmtLarge(val); +} + +function FinRow({ row, lastColIdx }: { row: FinancialRow; lastColIdx: number }) { + if (row.is_section) { + return ( + <tr className="psm-fin-section-row"> + <td className="psm-fin-section-label" colSpan={lastColIdx + 2}> + {row.label} + </td> + </tr> + ); + } + + const cls = [ + "psm-fin-row", + row.is_total ? "is-total" : "", + row.is_margin ? "is-margin" : "", + row.indent === 1 ? "is-indent" : "", + ] + .filter(Boolean) + .join(" "); + + return ( + <tr className={cls}> + <td className="psm-fin-label">{row.label}</td> + {row.values.map((val, i) => ( + <td + key={i} + className={[ + "psm-fin-val", + i === lastColIdx ? "accent" : "", + val !== null && val < 0 && !row.is_margin ? "neg" : "", + ] + .filter(Boolean) + .join(" ")} + > + {fmtFinVal(val, row.is_margin)} + </td> + ))} + </tr> + ); +} + +export function FinancialsCard({ + data, + statement, + period, + onChangeStatement, + onChangePeriod, +}: Props) { + const stmt = data[statement]; + const lastColIdx = stmt.columns.length - 1; + + return ( + <section className="psm-card psm-financials-card"> + <div className="psm-fin-header"> + <div className="psm-fin-tabs"> + {(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => ( + <button + key={key} + type="button" + className={`psm-fin-tab${statement === key ? " active" : ""}`} + onClick={() => onChangeStatement(key)} + > + {STMT_LABELS[key]} + </button> + ))} + </div> + <div className="psm-fin-period"> + {(["annual", "quarterly"] as PeriodKey[]).map((p) => ( + <button + key={p} + type="button" + className={`psm-fin-period-btn${period === p ? " active" : ""}`} + onClick={() => onChangePeriod(p)} + > + {p === "annual" ? "ANNUAL" : "QUARTERLY"} + </button> + ))} + </div> + </div> + + {stmt.columns.length === 0 ? ( + <p className="psm-muted-copy psm-fin-empty">Statement data unavailable.</p> + ) : ( + <div className="psm-fin-table-wrap"> + <table className="psm-fin-table"> + <thead> + <tr> + <th className="psm-fin-label-col">USD (millions)</th> + {stmt.columns.map((col, i) => ( + <th + key={i} + className={`psm-fin-val-col${i === lastColIdx ? " accent" : ""}`} + > + {col} + </th> + ))} + </tr> + </thead> + <tbody> + {stmt.rows.map((row, i) => ( + <FinRow key={i} row={row} lastColIdx={lastColIdx} /> + ))} + </tbody> + </table> + </div> + )} + </section> + ); +} diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx new file mode 100644 index 0000000..fcd2763 --- /dev/null +++ b/frontend/components/prism/FinancialsPage.tsx @@ -0,0 +1,73 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { buildKpis } from "@/lib/overview"; +import { FinancialsCard } from "@/components/prism/FinancialsCard"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import type { FinancialsResponse, TickerOverview } from "@/types/api"; + +type StatementKey = "income" | "balance" | "cash_flow"; +type PeriodKey = "annual" | "quarterly"; +type FinState = "loading" | "ready" | "error"; + +type Props = { + ticker: string; + overview: TickerOverview; + isSaved: boolean; + onToggleWatchlist: () => void; +}; + +export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) { + const [statement, setStatement] = useState<StatementKey>("income"); + const [period, setPeriod] = useState<PeriodKey>("annual"); + const [data, setData] = useState<FinancialsResponse | null>(null); + const [finState, setFinState] = useState<FinState>("loading"); + const kpis = buildKpis(overview); + + useEffect(() => { + let cancelled = false; + setFinState("loading"); + setData(null); + + api + .financials(ticker, period) + .then((res) => { + if (!cancelled) { + setData(res); + setFinState("ready"); + } + }) + .catch(() => { + if (!cancelled) setFinState("error"); + }); + + return () => { + cancelled = true; + }; + }, [ticker, period]); + + return ( + <> + <TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} /> + <KPIStrip items={kpis} /> + {finState === "loading" && ( + <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} /> + )} + {finState === "error" && ( + <section className="psm-card"> + <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p> + </section> + )} + {finState === "ready" && data && ( + <FinancialsCard + data={data} + statement={statement} + period={period} + onChangeStatement={setStatement} + onChangePeriod={setPeriod} + /> + )} + </> + ); +} diff --git a/frontend/components/prism/Sidebar.tsx b/frontend/components/prism/Sidebar.tsx index 7f106d8..fb8ebcf 100644 --- a/frontend/components/prism/Sidebar.tsx +++ b/frontend/components/prism/Sidebar.tsx @@ -12,6 +12,7 @@ type Props = { watchlistError: string | null; onSelectTicker: (symbol: string) => void; onRemoveTicker: (symbol: string) => void; + onSelectTab: (key: string) => void; }; export function Sidebar({ @@ -21,7 +22,8 @@ export function Sidebar({ watchlist, watchlistError, onSelectTicker, - onRemoveTicker + onRemoveTicker, + onSelectTab }: Props) { return ( <aside className="psm-side"> @@ -46,6 +48,7 @@ export function Sidebar({ type="button" className={`psm-nav-item${active ? " active" : ""}${item.disabled ? " disabled" : ""}`} disabled={item.disabled} + onClick={item.disabled ? undefined : () => onSelectTab(item.key)} > <span className={`psm-icon icon-${item.icon}`} aria-hidden /> <span className="psm-nav-copy"> |
