1597 lines
53 KiB
TypeScript
1597 lines
53 KiB
TypeScript
"use client";
|
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import Link from "next/link";
|
|
import {
|
|
Fragment,
|
|
Suspense,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { format } from "date-fns";
|
|
import { useSearchParams } from "next/navigation";
|
|
import {
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
import {
|
|
AlertTriangle,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Download,
|
|
RefreshCcw,
|
|
Search,
|
|
} from "lucide-react";
|
|
import { NormalizationSummary } from "@/components/financials/normalization-summary";
|
|
import { StatementMatrix } from "@/components/financials/statement-matrix";
|
|
import { StatementRowInspector } from "@/components/financials/statement-row-inspector";
|
|
import { AppShell } from "@/components/shell/app-shell";
|
|
import {
|
|
FinancialsToolbar,
|
|
type FinancialControlSection,
|
|
} from "@/components/financials/financials-toolbar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Panel } from "@/components/ui/panel";
|
|
import { useAuthGuard } from "@/hooks/use-auth-guard";
|
|
import { useLinkPrefetch } from "@/hooks/use-link-prefetch";
|
|
import { ensureTickerAutomation, queueFilingSync } from "@/lib/api";
|
|
import {
|
|
formatCurrencyByScale,
|
|
formatFinancialStatementValue,
|
|
formatPercent,
|
|
type NumberScaleUnit,
|
|
} from "@/lib/format";
|
|
import { buildGraphingHref } from "@/lib/graphing/catalog";
|
|
import { mergeFinancialPages } from "@/lib/financials/page-merge";
|
|
import {
|
|
buildStatementTree,
|
|
resolveStatementSelection,
|
|
type StatementInspectorSelection,
|
|
} from "@/lib/financials/statement-view-model";
|
|
import {
|
|
filterPeriodsByHistoryWindow,
|
|
financialHistoryLimit,
|
|
type FinancialHistoryWindow,
|
|
} from "@/lib/financials/history-window";
|
|
import { queryKeys } from "@/lib/query/keys";
|
|
import { companyFinancialStatementsQueryOptions } from "@/lib/query/options";
|
|
import type {
|
|
CompanyFinancialStatementsResponse,
|
|
DetailFinancialRow,
|
|
DerivedFinancialRow,
|
|
FinancialCadence,
|
|
FinancialDisplayMode,
|
|
FinancialSurfaceKind,
|
|
FinancialUnit,
|
|
RatioRow,
|
|
StandardizedFinancialRow,
|
|
StructuredKpiRow,
|
|
SurfaceFinancialRow,
|
|
TaxonomyStatementRow,
|
|
} from "@/lib/types";
|
|
|
|
type LoadOptions = {
|
|
cursor?: string | null;
|
|
append?: boolean;
|
|
};
|
|
|
|
type FlatDisplayRow =
|
|
| TaxonomyStatementRow
|
|
| StandardizedFinancialRow
|
|
| RatioRow
|
|
| StructuredKpiRow;
|
|
type StatementMatrixRow = SurfaceFinancialRow | DetailFinancialRow;
|
|
|
|
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{
|
|
value: NumberScaleUnit;
|
|
label: string;
|
|
}> = [
|
|
{ value: "thousands", label: "Thousands (K)" },
|
|
{ value: "millions", label: "Millions (M)" },
|
|
{ value: "billions", label: "Billions (B)" },
|
|
];
|
|
|
|
const SURFACE_OPTIONS = [
|
|
{ value: "income_statement", label: "Income" },
|
|
{ value: "balance_sheet", label: "Balance" },
|
|
{ value: "cash_flow_statement", label: "Cash Flow" },
|
|
{ value: "equity_statement", label: "Stockholders' Equity" },
|
|
{ value: "disclosures", label: "Disclosures" },
|
|
{ value: "ratios", label: "Ratios" },
|
|
{ value: "segments_kpis", label: "KPIs" },
|
|
];
|
|
|
|
const CADENCE_OPTIONS = [
|
|
{ value: "annual", label: "Annual" },
|
|
{ value: "quarterly", label: "Quarterly" },
|
|
{ value: "ltm", label: "LTM" },
|
|
];
|
|
|
|
const HISTORY_WINDOW_OPTIONS: Array<{
|
|
value: `${FinancialHistoryWindow}`;
|
|
label: string;
|
|
}> = [
|
|
{ value: "3", label: "3Y" },
|
|
{ value: "5", label: "5Y" },
|
|
{ value: "10", label: "10Y" },
|
|
];
|
|
|
|
const DISPLAY_MODE_OPTIONS = [
|
|
{ value: "standardized", label: "Standard" },
|
|
{ value: "faithful", label: "Faithful" },
|
|
];
|
|
|
|
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)";
|
|
|
|
function formatLongDate(value: string) {
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return "Unknown";
|
|
}
|
|
|
|
return format(parsed, "MMM dd, yyyy");
|
|
}
|
|
|
|
function isTaxonomyRow(row: FlatDisplayRow): row is TaxonomyStatementRow {
|
|
return "localName" in row;
|
|
}
|
|
|
|
function isDerivedRow(row: FlatDisplayRow): row is DerivedFinancialRow {
|
|
return "formulaKey" in row;
|
|
}
|
|
|
|
function isKpiRow(row: FlatDisplayRow): row is StructuredKpiRow {
|
|
return "provenanceType" in row;
|
|
}
|
|
|
|
function rowValue(row: FlatDisplayRow, periodId: string) {
|
|
return row.values[periodId] ?? null;
|
|
}
|
|
|
|
function statementRowValue(row: StatementMatrixRow, periodId: string) {
|
|
return row.values[periodId] ?? null;
|
|
}
|
|
|
|
function isStatementSurfaceKind(surfaceKind: FinancialSurfaceKind) {
|
|
return (
|
|
surfaceKind === "income_statement" ||
|
|
surfaceKind === "balance_sheet" ||
|
|
surfaceKind === "cash_flow_statement" ||
|
|
surfaceKind === "equity_statement" ||
|
|
surfaceKind === "disclosures"
|
|
);
|
|
}
|
|
|
|
function formatMetricValue(input: {
|
|
value: number | null;
|
|
unit: FinancialUnit;
|
|
scale: NumberScaleUnit;
|
|
rowKey?: string | null;
|
|
surfaceKind: FinancialSurfaceKind;
|
|
isPercentChange?: boolean;
|
|
isCommonSize?: boolean;
|
|
}) {
|
|
if (input.value === null) {
|
|
return isStatementSurfaceKind(input.surfaceKind) ? "—" : "n/a";
|
|
}
|
|
|
|
if (isStatementSurfaceKind(input.surfaceKind)) {
|
|
return formatFinancialStatementValue({
|
|
value: input.value,
|
|
unit: input.unit,
|
|
scale: input.scale,
|
|
rowKey: input.rowKey,
|
|
surfaceKind: input.surfaceKind,
|
|
isPercentChange: input.isPercentChange,
|
|
isCommonSize: input.isCommonSize,
|
|
});
|
|
}
|
|
|
|
switch (input.unit) {
|
|
case "currency":
|
|
return formatCurrencyByScale(input.value, input.scale);
|
|
case "percent":
|
|
return formatPercent(input.value * 100);
|
|
case "shares":
|
|
case "count":
|
|
return new Intl.NumberFormat("en-US", {
|
|
notation: "compact",
|
|
maximumFractionDigits: 2,
|
|
}).format(input.value);
|
|
case "ratio":
|
|
return `${input.value.toFixed(2)}x`;
|
|
default:
|
|
return String(input.value);
|
|
}
|
|
}
|
|
|
|
function chartTickFormatter(
|
|
value: number,
|
|
unit: FinancialUnit,
|
|
scale: NumberScaleUnit,
|
|
surfaceKind: FinancialSurfaceKind,
|
|
rowKey?: string | null,
|
|
) {
|
|
if (!Number.isFinite(value)) {
|
|
return isStatementSurfaceKind(surfaceKind) ? "—" : "n/a";
|
|
}
|
|
|
|
return formatMetricValue({ value, unit, scale, surfaceKind, rowKey });
|
|
}
|
|
|
|
function buildDisplayValue(input: {
|
|
row: FlatDisplayRow;
|
|
periodId: string;
|
|
previousPeriodId: string | null;
|
|
commonSizeRow: FlatDisplayRow | null;
|
|
displayMode: FinancialDisplayMode;
|
|
showPercentChange: boolean;
|
|
showCommonSize: boolean;
|
|
scale: NumberScaleUnit;
|
|
surfaceKind: FinancialSurfaceKind;
|
|
}) {
|
|
const current = rowValue(input.row, input.periodId);
|
|
|
|
if (input.showPercentChange && input.previousPeriodId) {
|
|
const previous = rowValue(input.row, input.previousPeriodId);
|
|
if (current === null || previous === null || previous === 0) {
|
|
return isStatementSurfaceKind(input.surfaceKind) ? "—" : "n/a";
|
|
}
|
|
|
|
return formatMetricValue({
|
|
value: (current - previous) / previous,
|
|
unit: "percent",
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
isPercentChange: true,
|
|
});
|
|
}
|
|
|
|
if (input.showCommonSize) {
|
|
if (
|
|
input.surfaceKind === "ratios" &&
|
|
isDerivedRow(input.row) &&
|
|
input.row.unit === "percent"
|
|
) {
|
|
return formatPercent((current ?? 0) * 100);
|
|
}
|
|
|
|
if (input.displayMode === "faithful") {
|
|
return isStatementSurfaceKind(input.surfaceKind) ? "—" : "n/a";
|
|
}
|
|
|
|
const denominator = input.commonSizeRow
|
|
? rowValue(input.commonSizeRow, input.periodId)
|
|
: null;
|
|
if (current === null || denominator === null || denominator === 0) {
|
|
return isStatementSurfaceKind(input.surfaceKind) ? "—" : "n/a";
|
|
}
|
|
|
|
return formatMetricValue({
|
|
value: current / denominator,
|
|
unit: "percent",
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
isCommonSize: true,
|
|
});
|
|
}
|
|
|
|
const unit = isTaxonomyRow(input.row) ? "currency" : input.row.unit;
|
|
return formatMetricValue({
|
|
value: current,
|
|
unit,
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
});
|
|
}
|
|
|
|
function unitFromDetailRow(row: DetailFinancialRow): FinancialUnit {
|
|
const normalizedUnit = row.unit?.toLowerCase() ?? "";
|
|
|
|
if (normalizedUnit.includes("shares")) {
|
|
return "shares";
|
|
}
|
|
|
|
if (normalizedUnit === "pure" || normalizedUnit.includes("ratio")) {
|
|
return "ratio";
|
|
}
|
|
|
|
if (normalizedUnit.includes("percent")) {
|
|
return "percent";
|
|
}
|
|
|
|
return "currency";
|
|
}
|
|
|
|
function buildStatementTreeDisplayValue(input: {
|
|
row: StatementMatrixRow;
|
|
periodId: string;
|
|
previousPeriodId: string | null;
|
|
commonSizeRow: SurfaceFinancialRow | null;
|
|
showPercentChange: boolean;
|
|
showCommonSize: boolean;
|
|
scale: NumberScaleUnit;
|
|
surfaceKind: Extract<
|
|
FinancialSurfaceKind,
|
|
| "income_statement"
|
|
| "balance_sheet"
|
|
| "cash_flow_statement"
|
|
| "equity_statement"
|
|
| "disclosures"
|
|
>;
|
|
}) {
|
|
const current = statementRowValue(input.row, input.periodId);
|
|
|
|
if (input.showPercentChange && input.previousPeriodId) {
|
|
const previous = statementRowValue(input.row, input.previousPeriodId);
|
|
if (current === null || previous === null || previous === 0) {
|
|
return "—";
|
|
}
|
|
|
|
return formatMetricValue({
|
|
value: (current - previous) / previous,
|
|
unit: "percent",
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
isPercentChange: true,
|
|
});
|
|
}
|
|
|
|
if (input.showCommonSize) {
|
|
const denominator = input.commonSizeRow
|
|
? statementRowValue(input.commonSizeRow, input.periodId)
|
|
: null;
|
|
if (current === null || denominator === null || denominator === 0) {
|
|
return "—";
|
|
}
|
|
|
|
return formatMetricValue({
|
|
value: current / denominator,
|
|
unit: "percent",
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
isCommonSize: true,
|
|
});
|
|
}
|
|
|
|
return formatMetricValue({
|
|
value: current,
|
|
unit:
|
|
"conceptKey" in input.row ? unitFromDetailRow(input.row) : input.row.unit,
|
|
scale: input.scale,
|
|
rowKey: input.row.key,
|
|
surfaceKind: input.surfaceKind,
|
|
});
|
|
}
|
|
|
|
function groupRows(
|
|
rows: FlatDisplayRow[],
|
|
categories: CompanyFinancialStatementsResponse["categories"],
|
|
) {
|
|
if (categories.length === 0) {
|
|
return [{ label: null, rows }];
|
|
}
|
|
|
|
return categories
|
|
.map((category) => ({
|
|
label: category.label,
|
|
rows: rows.filter(
|
|
(row) => !isTaxonomyRow(row) && row.category === category.key,
|
|
),
|
|
}))
|
|
.filter((group) => group.rows.length > 0);
|
|
}
|
|
|
|
function ChartFrame({ children }: { children: React.ReactNode }) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const element = containerRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
const next = entries[0];
|
|
if (!next) {
|
|
return;
|
|
}
|
|
|
|
const { width, height } = next.contentRect;
|
|
setReady(width > 0 && height > 0);
|
|
});
|
|
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={containerRef} className="h-[320px]">
|
|
{ready ? children : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CollapsibleSection(props: {
|
|
title: string;
|
|
subtitle?: string;
|
|
children: React.ReactNode;
|
|
defaultOpen?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(props.defaultOpen ?? false);
|
|
|
|
return (
|
|
<section className="border-t border-[color:var(--line-weak)] pt-4">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-start justify-between gap-3 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-left transition hover:border-[color:var(--line-strong)]"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((current) => !current)}
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-semibold text-[color:var(--terminal-bright)]">
|
|
{props.title}
|
|
</span>
|
|
{props.subtitle ? (
|
|
<span className="mt-0.5 block text-xs text-[color:var(--terminal-muted)]">
|
|
{props.subtitle}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="mt-0.5 shrink-0 text-[color:var(--terminal-muted)]">
|
|
{open ? (
|
|
<ChevronDown className="size-4" />
|
|
) : (
|
|
<ChevronRight className="size-4" />
|
|
)}
|
|
</span>
|
|
</button>
|
|
{open ? <div className="pt-4">{props.children}</div> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SummarySubsection(props: {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">
|
|
{props.title}
|
|
</h3>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FinancialsPageContent() {
|
|
const { isPending, isAuthenticated } = useAuthGuard();
|
|
const searchParams = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const { prefetchResearchTicker } = useLinkPrefetch();
|
|
|
|
const [tickerInput, setTickerInput] = useState("MSFT");
|
|
const [ticker, setTicker] = useState("MSFT");
|
|
const [surfaceKind, setSurfaceKind] =
|
|
useState<FinancialSurfaceKind>("income_statement");
|
|
const [cadence, setCadence] = useState<FinancialCadence>("annual");
|
|
const [displayMode, setDisplayMode] =
|
|
useState<FinancialDisplayMode>("standardized");
|
|
const [historyWindow, setHistoryWindow] = useState<FinancialHistoryWindow>(3);
|
|
const [valueScale, setValueScale] = useState<NumberScaleUnit>("millions");
|
|
const [rowSearch, setRowSearch] = useState("");
|
|
const [showPercentChange, setShowPercentChange] = useState(false);
|
|
const [showCommonSize, setShowCommonSize] = useState(false);
|
|
const [financials, setFinancials] =
|
|
useState<CompanyFinancialStatementsResponse | null>(null);
|
|
const [selectedFlatRowKey, setSelectedFlatRowKey] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [selectedRowRef, setSelectedRowRef] =
|
|
useState<StatementInspectorSelection | null>(null);
|
|
const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [loading, setLoading] = useState(true);
|
|
const [, setLoadingMore] = useState(false);
|
|
const [syncingFinancials, setSyncingFinancials] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showTrendChart, setShowTrendChart] = useState(false);
|
|
const historyFetchLimit = useMemo(
|
|
() => financialHistoryLimit(cadence, historyWindow),
|
|
[cadence, historyWindow],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const fromQuery = searchParams.get("ticker");
|
|
if (!fromQuery) {
|
|
return;
|
|
}
|
|
|
|
const normalized = fromQuery.trim().toUpperCase();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
setTickerInput(normalized);
|
|
setTicker(normalized);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
const statementSurface =
|
|
surfaceKind === "income_statement" ||
|
|
surfaceKind === "balance_sheet" ||
|
|
surfaceKind === "cash_flow_statement" ||
|
|
surfaceKind === "equity_statement" ||
|
|
surfaceKind === "disclosures";
|
|
if (!statementSurface && displayMode !== "standardized") {
|
|
setDisplayMode("standardized");
|
|
}
|
|
|
|
if (displayMode === "faithful") {
|
|
setShowPercentChange(false);
|
|
setShowCommonSize(false);
|
|
}
|
|
|
|
if (surfaceKind === "adjusted" || surfaceKind === "custom_metrics") {
|
|
setShowPercentChange(false);
|
|
setShowCommonSize(false);
|
|
}
|
|
}, [displayMode, surfaceKind]);
|
|
|
|
useEffect(() => {
|
|
if (!financials?.displayModes.includes(displayMode)) {
|
|
setDisplayMode(financials?.defaultDisplayMode ?? "standardized");
|
|
}
|
|
}, [displayMode, financials?.defaultDisplayMode, financials?.displayModes]);
|
|
|
|
const loadFinancials = useCallback(
|
|
async (symbol: string, options?: LoadOptions) => {
|
|
const normalizedTicker = symbol.trim().toUpperCase();
|
|
const nextCursor = options?.cursor ?? null;
|
|
const includeDimensions =
|
|
(selectedFlatRowKey !== null || selectedRowRef !== null) &&
|
|
(surfaceKind === "segments_kpis" ||
|
|
surfaceKind === "income_statement" ||
|
|
surfaceKind === "balance_sheet" ||
|
|
surfaceKind === "cash_flow_statement" ||
|
|
surfaceKind === "equity_statement" ||
|
|
surfaceKind === "disclosures");
|
|
|
|
if (!options?.append) {
|
|
setLoading(true);
|
|
} else {
|
|
setLoadingMore(true);
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await queryClient.ensureQueryData(
|
|
companyFinancialStatementsQueryOptions({
|
|
ticker: normalizedTicker,
|
|
surfaceKind,
|
|
cadence,
|
|
includeDimensions,
|
|
includeFacts: false,
|
|
cursor: nextCursor,
|
|
limit: historyFetchLimit,
|
|
}),
|
|
);
|
|
|
|
setFinancials((current) =>
|
|
options?.append
|
|
? mergeFinancialPages(current, response.financials)
|
|
: response.financials,
|
|
);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Unable to load financial history",
|
|
);
|
|
if (!options?.append) {
|
|
setFinancials(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[
|
|
cadence,
|
|
historyFetchLimit,
|
|
queryClient,
|
|
selectedFlatRowKey,
|
|
selectedRowRef,
|
|
surfaceKind,
|
|
],
|
|
);
|
|
|
|
const syncFinancials = useCallback(async () => {
|
|
const targetTicker = (financials?.company.ticker ?? ticker)
|
|
.trim()
|
|
.toUpperCase();
|
|
if (!targetTicker) {
|
|
return;
|
|
}
|
|
|
|
setSyncingFinancials(true);
|
|
setError(null);
|
|
|
|
try {
|
|
await queueFilingSync({ ticker: targetTicker, limit: 20 });
|
|
void queryClient.invalidateQueries({
|
|
queryKey: queryKeys.recentTasks(20),
|
|
});
|
|
void queryClient.invalidateQueries({ queryKey: ["filings"] });
|
|
void queryClient.invalidateQueries({ queryKey: ["financials-v3"] });
|
|
await loadFinancials(targetTicker);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: `Failed to queue financial sync for ${targetTicker}`,
|
|
);
|
|
} finally {
|
|
setSyncingFinancials(false);
|
|
}
|
|
}, [financials?.company.ticker, loadFinancials, queryClient, ticker]);
|
|
|
|
useEffect(() => {
|
|
if (!isPending && isAuthenticated) {
|
|
void loadFinancials(ticker);
|
|
}
|
|
}, [isPending, isAuthenticated, loadFinancials, ticker]);
|
|
|
|
const periods = useMemo(() => {
|
|
return [...(financials?.periods ?? [])].sort(
|
|
(left, right) =>
|
|
Date.parse(left.periodEnd ?? left.filingDate) -
|
|
Date.parse(right.periodEnd ?? right.filingDate),
|
|
);
|
|
}, [financials?.periods]);
|
|
const visiblePeriods = useMemo(
|
|
() => filterPeriodsByHistoryWindow(periods, cadence, historyWindow),
|
|
[cadence, historyWindow, periods],
|
|
);
|
|
|
|
const isTreeStatementMode =
|
|
displayMode === "standardized" && isStatementSurfaceKind(surfaceKind);
|
|
|
|
const activeRows = useMemo<FlatDisplayRow[]>(() => {
|
|
if (!financials) {
|
|
return [];
|
|
}
|
|
|
|
switch (surfaceKind) {
|
|
case "income_statement":
|
|
case "balance_sheet":
|
|
case "cash_flow_statement":
|
|
case "equity_statement":
|
|
case "disclosures":
|
|
return displayMode === "faithful"
|
|
? (financials.statementRows?.faithful ?? [])
|
|
: (financials.statementRows?.standardized ?? []);
|
|
case "ratios":
|
|
return financials.ratioRows ?? [];
|
|
case "segments_kpis":
|
|
return financials.kpiRows ?? [];
|
|
default:
|
|
return [];
|
|
}
|
|
}, [displayMode, financials, surfaceKind]);
|
|
|
|
const filteredRows = useMemo(() => {
|
|
if (isTreeStatementMode) {
|
|
return activeRows;
|
|
}
|
|
|
|
const normalizedSearch = rowSearch.trim().toLowerCase();
|
|
if (!normalizedSearch) {
|
|
return activeRows;
|
|
}
|
|
|
|
return activeRows.filter((row) =>
|
|
row.label.toLowerCase().includes(normalizedSearch),
|
|
);
|
|
}, [activeRows, isTreeStatementMode, rowSearch]);
|
|
|
|
const groupedRows = useMemo(() => {
|
|
return groupRows(filteredRows, financials?.categories ?? []);
|
|
}, [filteredRows, financials?.categories]);
|
|
|
|
const treeModel = useMemo(() => {
|
|
if (
|
|
!isTreeStatementMode ||
|
|
!financials ||
|
|
!isStatementSurfaceKind(surfaceKind)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return buildStatementTree({
|
|
surfaceKind,
|
|
rows: financials.statementRows?.standardized ?? [],
|
|
statementDetails: financials.statementDetails,
|
|
categories: financials.categories,
|
|
searchQuery: rowSearch,
|
|
expandedRowKeys,
|
|
});
|
|
}, [
|
|
expandedRowKeys,
|
|
financials,
|
|
isTreeStatementMode,
|
|
rowSearch,
|
|
surfaceKind,
|
|
]);
|
|
|
|
const selectedRow = useMemo(() => {
|
|
if (!selectedFlatRowKey) {
|
|
return null;
|
|
}
|
|
|
|
return activeRows.find((row) => row.key === selectedFlatRowKey) ?? null;
|
|
}, [activeRows, selectedFlatRowKey]);
|
|
|
|
const selectedStatementRow = useMemo(() => {
|
|
if (
|
|
!isTreeStatementMode ||
|
|
!financials ||
|
|
!isStatementSurfaceKind(surfaceKind)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return resolveStatementSelection({
|
|
surfaceKind,
|
|
rows: financials.statementRows?.standardized ?? [],
|
|
statementDetails: financials.statementDetails,
|
|
selection: selectedRowRef,
|
|
});
|
|
}, [financials, isTreeStatementMode, selectedRowRef, surfaceKind]);
|
|
|
|
const dimensionRows = useMemo(() => {
|
|
if (!financials?.dimensionBreakdown) {
|
|
return [];
|
|
}
|
|
|
|
if (selectedStatementRow?.kind === "surface") {
|
|
return financials.dimensionBreakdown[selectedStatementRow.row.key] ?? [];
|
|
}
|
|
|
|
if (selectedStatementRow?.kind === "detail") {
|
|
return (
|
|
financials.dimensionBreakdown[selectedStatementRow.row.key] ??
|
|
financials.dimensionBreakdown[
|
|
selectedStatementRow.row.parentSurfaceKey
|
|
] ??
|
|
[]
|
|
);
|
|
}
|
|
|
|
if (!selectedRow) {
|
|
return [];
|
|
}
|
|
|
|
return financials.dimensionBreakdown[selectedRow.key] ?? [];
|
|
}, [financials?.dimensionBreakdown, selectedRow, selectedStatementRow]);
|
|
|
|
const commonSizeRow = useMemo<SurfaceFinancialRow | null>(() => {
|
|
if (displayMode === "faithful" || !financials?.statementRows) {
|
|
return null;
|
|
}
|
|
|
|
const standardizedRows = financials.statementRows.standardized;
|
|
if (
|
|
surfaceKind === "income_statement" ||
|
|
surfaceKind === "cash_flow_statement"
|
|
) {
|
|
return (
|
|
standardizedRows.find((row) => row.key === "revenue") ??
|
|
standardizedRows.find((row) => row.key === "net_cash_from_operating") ??
|
|
standardizedRows.find((row) => row.key === "operating_income") ??
|
|
null
|
|
);
|
|
}
|
|
|
|
if (surfaceKind === "balance_sheet") {
|
|
return (
|
|
standardizedRows.find((row) => row.key === "total_assets") ??
|
|
standardizedRows.find(
|
|
(row) => row.key === "total_liabilities_and_equity",
|
|
) ??
|
|
standardizedRows.find((row) => row.key === "stockholders_equity") ??
|
|
null
|
|
);
|
|
}
|
|
|
|
if (surfaceKind === "equity_statement") {
|
|
return (
|
|
standardizedRows.find((row) => row.key === "total_equity") ??
|
|
standardizedRows.find((row) => row.key === "retained_earnings") ??
|
|
standardizedRows.find((row) => row.key === "common_stock_and_apic") ??
|
|
null
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}, [displayMode, financials?.statementRows, surfaceKind]);
|
|
|
|
const hasUnmappedResidualRows = useMemo(() => {
|
|
return (financials?.statementDetails?.unmapped?.length ?? 0) > 0;
|
|
}, [financials?.statementDetails]);
|
|
|
|
const trendSeries = financials?.trendSeries ?? [];
|
|
const chartData = useMemo(() => {
|
|
return visiblePeriods.map((period) => ({
|
|
label: formatLongDate(period.periodEnd ?? period.filingDate),
|
|
...Object.fromEntries(
|
|
trendSeries.map((series) => [
|
|
series.key,
|
|
series.values[period.id] ?? null,
|
|
]),
|
|
),
|
|
}));
|
|
}, [trendSeries, visiblePeriods]);
|
|
|
|
const controlSections = useMemo<FinancialControlSection[]>(() => {
|
|
const availableDisplayModeOptions = DISPLAY_MODE_OPTIONS.filter((option) =>
|
|
financials?.displayModes
|
|
? financials.displayModes.includes(option.value as FinancialDisplayMode)
|
|
: option.value === "standardized" || surfaceKind !== "equity_statement",
|
|
);
|
|
const sections: FinancialControlSection[] = [
|
|
{
|
|
key: "surface",
|
|
label: "Surface",
|
|
options: SURFACE_OPTIONS,
|
|
value: surfaceKind,
|
|
onChange: (value) => {
|
|
setSurfaceKind(value as FinancialSurfaceKind);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
},
|
|
},
|
|
{
|
|
key: "cadence",
|
|
label: "Cadence",
|
|
options: CADENCE_OPTIONS,
|
|
value: cadence,
|
|
onChange: (value) => {
|
|
setCadence(value as FinancialCadence);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
},
|
|
},
|
|
{
|
|
key: "history",
|
|
label: "History",
|
|
options: HISTORY_WINDOW_OPTIONS,
|
|
value: String(historyWindow),
|
|
onChange: (value) =>
|
|
setHistoryWindow(
|
|
Number.parseInt(value, 10) as FinancialHistoryWindow,
|
|
),
|
|
},
|
|
];
|
|
|
|
if (
|
|
surfaceKind === "income_statement" ||
|
|
surfaceKind === "balance_sheet" ||
|
|
surfaceKind === "cash_flow_statement" ||
|
|
surfaceKind === "equity_statement" ||
|
|
surfaceKind === "disclosures"
|
|
) {
|
|
sections.push({
|
|
key: "display",
|
|
label: "Display",
|
|
options: availableDisplayModeOptions,
|
|
value: displayMode,
|
|
onChange: (value) => {
|
|
setDisplayMode(value as FinancialDisplayMode);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
},
|
|
});
|
|
}
|
|
|
|
sections.push({
|
|
key: "scale",
|
|
label: "Scale",
|
|
options: FINANCIAL_VALUE_SCALE_OPTIONS,
|
|
value: valueScale,
|
|
onChange: (value) => setValueScale(value as NumberScaleUnit),
|
|
});
|
|
|
|
return sections;
|
|
}, [cadence, displayMode, financials?.displayModes, historyWindow, surfaceKind, valueScale]);
|
|
|
|
const toggleExpandedRow = useCallback((key: string) => {
|
|
setExpandedRowKeys((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const rowResultCountLabel = useMemo(() => {
|
|
if (treeModel) {
|
|
return `${treeModel.visibleNodeCount} of ${treeModel.totalNodeCount} visible rows`;
|
|
}
|
|
|
|
return `${filteredRows.length} of ${activeRows.length} rows`;
|
|
}, [activeRows.length, filteredRows.length, treeModel]);
|
|
|
|
const valueScaleLabel =
|
|
FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)
|
|
?.label ?? valueScale;
|
|
|
|
const renderStatementTreeCellValue = useCallback(
|
|
(
|
|
row: StatementMatrixRow,
|
|
periodId: string,
|
|
previousPeriodId: string | null,
|
|
) => {
|
|
if (!isStatementSurfaceKind(surfaceKind)) {
|
|
return "—";
|
|
}
|
|
|
|
return buildStatementTreeDisplayValue({
|
|
row,
|
|
periodId,
|
|
previousPeriodId,
|
|
commonSizeRow,
|
|
showPercentChange,
|
|
showCommonSize,
|
|
scale: valueScale,
|
|
surfaceKind,
|
|
});
|
|
},
|
|
[commonSizeRow, showCommonSize, showPercentChange, surfaceKind, valueScale],
|
|
);
|
|
|
|
const renderDimensionValue = useCallback(
|
|
(value: number | null, rowKey: string, unit: FinancialUnit) => {
|
|
return formatMetricValue({
|
|
value,
|
|
unit,
|
|
scale: valueScale,
|
|
rowKey,
|
|
surfaceKind,
|
|
});
|
|
},
|
|
[surfaceKind, valueScale],
|
|
);
|
|
|
|
if (isPending || !isAuthenticated) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
|
Loading financial terminal...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
title="Financials"
|
|
subtitle="Statement, ratio, and KPI time series"
|
|
activeTicker={financials?.company.ticker ?? ticker}
|
|
actions={
|
|
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
|
<Button
|
|
variant="secondary"
|
|
size="compact"
|
|
disabled={syncingFinancials}
|
|
onClick={() => {
|
|
void syncFinancials();
|
|
}}
|
|
>
|
|
<Download className="size-4" />
|
|
{syncingFinancials ? "Syncing..." : "Sync"}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="compact"
|
|
onClick={() => {
|
|
void loadFinancials(ticker);
|
|
}}
|
|
>
|
|
<RefreshCcw className="size-4" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<Panel title="Company" subtitle="Load ticker" density="compact">
|
|
<form
|
|
className="flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap sm:items-center"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const normalized = tickerInput.trim().toUpperCase();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
void ensureTickerAutomation({
|
|
ticker: normalized,
|
|
source: "financials",
|
|
});
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
setTicker(normalized);
|
|
}}
|
|
>
|
|
<Input
|
|
value={tickerInput}
|
|
onChange={(event) =>
|
|
setTickerInput(event.target.value.toUpperCase())
|
|
}
|
|
placeholder="Ticker (AAPL)"
|
|
className="w-full sm:max-w-xs"
|
|
inputSize="compact"
|
|
/>
|
|
<Button type="submit" size="compact" className="w-full sm:w-auto">
|
|
<Search className="size-4" />
|
|
Load
|
|
</Button>
|
|
{financials ? (
|
|
<>
|
|
<Link
|
|
href={`/analysis?ticker=${financials.company.ticker}`}
|
|
onMouseEnter={() =>
|
|
prefetchResearchTicker(financials.company.ticker)
|
|
}
|
|
onFocus={() =>
|
|
prefetchResearchTicker(financials.company.ticker)
|
|
}
|
|
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
|
>
|
|
Overview
|
|
</Link>
|
|
<Link
|
|
href={buildGraphingHref(financials.company.ticker)}
|
|
onMouseEnter={() =>
|
|
prefetchResearchTicker(financials.company.ticker)
|
|
}
|
|
onFocus={() =>
|
|
prefetchResearchTicker(financials.company.ticker)
|
|
}
|
|
className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
|
>
|
|
Graphing
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</form>
|
|
</Panel>
|
|
|
|
{error ? (
|
|
<Panel variant="flat" density="compact">
|
|
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
|
</Panel>
|
|
) : null}
|
|
|
|
<Panel variant="flat" density="compact">
|
|
<FinancialsToolbar
|
|
sections={controlSections}
|
|
searchValue={rowSearch}
|
|
onSearchChange={setRowSearch}
|
|
onExport={() => {
|
|
console.log("Export clicked");
|
|
}}
|
|
showChart={showTrendChart}
|
|
onToggleChart={() => setShowTrendChart(!showTrendChart)}
|
|
/>
|
|
<div className="mt-2 flex items-center text-sm text-[color:var(--terminal-muted)]">
|
|
{rowResultCountLabel}
|
|
</div>
|
|
</Panel>
|
|
|
|
{showTrendChart ? (
|
|
<Panel variant="flat" density="compact">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">
|
|
Trend Chart
|
|
</h3>
|
|
</div>
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
Loading trend chart...
|
|
</p>
|
|
) : chartData.length === 0 || trendSeries.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
No trend data available for the selected surface.
|
|
</p>
|
|
) : (
|
|
<ChartFrame>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
|
<XAxis
|
|
dataKey="label"
|
|
stroke={CHART_MUTED}
|
|
fontSize={12}
|
|
minTickGap={20}
|
|
/>
|
|
<YAxis
|
|
stroke={CHART_MUTED}
|
|
fontSize={12}
|
|
tickFormatter={(value: number) =>
|
|
chartTickFormatter(
|
|
value,
|
|
trendSeries[0]?.unit ?? "currency",
|
|
valueScale,
|
|
surfaceKind,
|
|
trendSeries[0]?.key ?? null,
|
|
)
|
|
}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value: unknown, _name, entry) => {
|
|
const numeric = Number(value);
|
|
const series = trendSeries.find(
|
|
(candidate) => candidate.key === entry.dataKey,
|
|
);
|
|
return formatMetricValue({
|
|
value: Number.isFinite(numeric) ? numeric : null,
|
|
unit: series?.unit ?? "currency",
|
|
scale: valueScale,
|
|
rowKey: series?.key ?? null,
|
|
surfaceKind,
|
|
});
|
|
}}
|
|
contentStyle={{
|
|
backgroundColor: CHART_TOOLTIP_BG,
|
|
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
|
borderRadius: "0.75rem",
|
|
}}
|
|
/>
|
|
{trendSeries.map((series, index) => (
|
|
<Line
|
|
key={series.key}
|
|
type="monotone"
|
|
dataKey={series.key}
|
|
name={series.label}
|
|
stroke={
|
|
["#d9dee5", "#c1c8d1", "#aab2bc", "#8f98a3"][index % 4]
|
|
}
|
|
strokeWidth={2}
|
|
dot={false}
|
|
/>
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</ChartFrame>
|
|
)}
|
|
</Panel>
|
|
) : null}
|
|
|
|
<Panel title="Matrix" variant="flat">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
Loading financial matrix...
|
|
</p>
|
|
) : surfaceKind === "adjusted" || surfaceKind === "custom_metrics" ? (
|
|
<div className="rounded-xl border border-dashed border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-4 py-6">
|
|
<p className="text-sm text-[color:var(--terminal-bright)]">
|
|
This surface is not yet available in v1.
|
|
</p>
|
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
|
Adjusted and custom metrics are API-visible placeholders only. No
|
|
edits or derived rows are available yet.
|
|
</p>
|
|
</div>
|
|
) : visiblePeriods.length === 0 ||
|
|
(isTreeStatementMode
|
|
? (treeModel?.visibleNodeCount ?? 0) === 0
|
|
: filteredRows.length === 0) ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
No rows available for the selected filters yet.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{isStatementSurfaceKind(surfaceKind) ? (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
|
USD · {valueScaleLabel} · Latest {historyWindow} years
|
|
</p>
|
|
{isTreeStatementMode && hasUnmappedResidualRows ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
Parser residual rows are available under the{" "}
|
|
<span className="text-[color:var(--terminal-bright)]">
|
|
Unmapped / Residual
|
|
</span>{" "}
|
|
section.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{isTreeStatementMode && treeModel ? (
|
|
<StatementMatrix
|
|
periods={visiblePeriods}
|
|
sections={treeModel.sections}
|
|
selectedRowRef={selectedRowRef}
|
|
onToggleRow={toggleExpandedRow}
|
|
onSelectRow={setSelectedRowRef}
|
|
renderCellValue={renderStatementTreeCellValue}
|
|
periodLabelFormatter={formatLongDate}
|
|
dense={true}
|
|
/>
|
|
) : (
|
|
<div className="data-table-wrap">
|
|
<table className="data-table financials-table min-w-[980px]">
|
|
<thead className="sticky top-0 z-20 bg-[color:var(--panel)]">
|
|
<tr>
|
|
<th className="bg-[color:var(--panel)]">Metric</th>
|
|
{visiblePeriods.map((period) => (
|
|
<th key={period.id}>
|
|
<div className="flex flex-col gap-1">
|
|
<span>
|
|
{formatLongDate(
|
|
period.periodEnd ?? period.filingDate,
|
|
)}
|
|
</span>
|
|
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">
|
|
{period.filingType} · {period.periodLabel}
|
|
</span>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{groupedRows.map((group) => (
|
|
<Fragment key={group.label ?? "ungrouped"}>
|
|
{group.label ? (
|
|
<tr className="bg-[color:var(--panel-soft)]">
|
|
<td
|
|
colSpan={visiblePeriods.length + 1}
|
|
className="font-semibold text-[color:var(--terminal-bright)]"
|
|
>
|
|
{group.label}
|
|
</td>
|
|
</tr>
|
|
) : null}
|
|
{group.rows.map((row) => (
|
|
<tr
|
|
key={row.key}
|
|
onClick={() => setSelectedFlatRowKey(row.key)}
|
|
>
|
|
<td className="bg-[color:var(--panel)]">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
style={{
|
|
paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px`,
|
|
}}
|
|
>
|
|
{row.label}
|
|
</span>
|
|
{"hasDimensions" in row &&
|
|
row.hasDimensions ? (
|
|
<ChevronDown className="size-3 text-[color:var(--accent)]" />
|
|
) : null}
|
|
</div>
|
|
{isDerivedRow(row) && row.formulaKey ? (
|
|
<span className="text-xs text-[color:var(--terminal-muted)]">
|
|
Formula: {row.formulaKey}
|
|
</span>
|
|
) : null}
|
|
{isKpiRow(row) ? (
|
|
<span className="text-xs text-[color:var(--terminal-muted)]">
|
|
Provenance: {row.provenanceType}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</td>
|
|
{visiblePeriods.map((period, index) => (
|
|
<td key={`${row.key}-${period.id}`}>
|
|
{buildDisplayValue({
|
|
row,
|
|
periodId: period.id,
|
|
previousPeriodId:
|
|
index > 0
|
|
? (visiblePeriods[index - 1]?.id ?? null)
|
|
: null,
|
|
commonSizeRow,
|
|
displayMode,
|
|
showPercentChange,
|
|
showCommonSize,
|
|
scale: valueScale,
|
|
surfaceKind,
|
|
})}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
{financials ? (
|
|
<CollapsibleSection
|
|
title="Normalization Summary"
|
|
subtitle="Row details, validation, and normalization diagnostics."
|
|
>
|
|
<div className="space-y-6">
|
|
<SummarySubsection title="Row Details">
|
|
{isTreeStatementMode && isStatementSurfaceKind(surfaceKind) ? (
|
|
<StatementRowInspector
|
|
selection={selectedStatementRow}
|
|
dimensionRows={dimensionRows}
|
|
periods={visiblePeriods}
|
|
surfaceKind={surfaceKind}
|
|
renderValue={renderStatementTreeCellValue}
|
|
renderDimensionValue={renderDimensionValue}
|
|
showHeader={false}
|
|
/>
|
|
) : (
|
|
<Panel variant="flat" density="compact">
|
|
{!selectedRow ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
Select a row to inspect details.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-4 text-sm">
|
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Label
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{selectedRow.label}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Key
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{selectedRow.key}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isTaxonomyRow(selectedRow) ? (
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Taxonomy Concept
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{selectedRow.qname}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Category
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{selectedRow.category}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Unit
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{selectedRow.unit}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isDerivedRow(selectedRow) ? (
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2.5 py-1.5">
|
|
<p className="text-[color:var(--terminal-muted)]">
|
|
Source Row Keys
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{(selectedRow.sourceRowKeys ?? []).join(", ") ||
|
|
"n/a"}
|
|
</p>
|
|
<p className="mt-2 text-[color:var(--terminal-muted)]">
|
|
Source Concepts
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{(selectedRow.sourceConcepts ?? []).join(", ") ||
|
|
"n/a"}
|
|
</p>
|
|
<p className="mt-2 text-[color:var(--terminal-muted)]">
|
|
Source Fact IDs
|
|
</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">
|
|
{(selectedRow.sourceFactIds ?? []).join(", ") ||
|
|
"n/a"}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{!selectedRow ||
|
|
!("hasDimensions" in selectedRow) ||
|
|
!selectedRow.hasDimensions ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
No dimensional drill-down is available for this row.
|
|
</p>
|
|
) : dimensionRows.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
No dimensional facts were returned for the selected
|
|
row.
|
|
</p>
|
|
) : (
|
|
<div className="data-table-wrap">
|
|
<table className="data-table min-w-[760px]">
|
|
<thead>
|
|
<tr>
|
|
<th>Period</th>
|
|
<th>Axis</th>
|
|
<th>Member</th>
|
|
<th>Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{dimensionRows.map((row, index) => (
|
|
<tr
|
|
key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}
|
|
>
|
|
<td>
|
|
{visiblePeriods.find(
|
|
(period) => period.id === row.periodId,
|
|
)?.periodLabel ?? row.periodId}
|
|
</td>
|
|
<td>{row.axis}</td>
|
|
<td>{row.member}</td>
|
|
<td>
|
|
{formatMetricValue({
|
|
value: row.value,
|
|
unit: isTaxonomyRow(selectedRow)
|
|
? "currency"
|
|
: selectedRow.unit,
|
|
scale: valueScale,
|
|
rowKey: selectedRow.key,
|
|
surfaceKind,
|
|
})}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
)}
|
|
</SummarySubsection>
|
|
|
|
{(surfaceKind === "income_statement" ||
|
|
surfaceKind === "balance_sheet" ||
|
|
surfaceKind === "cash_flow_statement" ||
|
|
surfaceKind === "equity_statement" ||
|
|
surfaceKind === "disclosures") && (
|
|
<SummarySubsection title="Validation">
|
|
<div className="mb-3 flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">
|
|
<AlertTriangle className="size-4" />
|
|
<span>
|
|
Overall status:{" "}
|
|
{financials.metrics.validation?.status ?? "not_run"}
|
|
</span>
|
|
</div>
|
|
{(financials.metrics.validation?.checks.length ?? 0) === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
|
No validation checks available yet.
|
|
</p>
|
|
) : (
|
|
<div className="data-table-wrap">
|
|
<table className="data-table min-w-[760px]">
|
|
<thead>
|
|
<tr>
|
|
<th>Metric</th>
|
|
<th>Taxonomy</th>
|
|
<th>LLM (PDF)</th>
|
|
<th>Status</th>
|
|
<th>Pages</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{financials.metrics.validation?.checks.map((check) => (
|
|
<tr key={check.metricKey}>
|
|
<td>{check.metricKey}</td>
|
|
<td>
|
|
{formatMetricValue({
|
|
value: check.taxonomyValue,
|
|
unit: "currency",
|
|
scale: valueScale,
|
|
rowKey: check.metricKey,
|
|
surfaceKind,
|
|
})}
|
|
</td>
|
|
<td>
|
|
{formatMetricValue({
|
|
value: check.llmValue,
|
|
unit: "currency",
|
|
scale: valueScale,
|
|
rowKey: check.metricKey,
|
|
surfaceKind,
|
|
})}
|
|
</td>
|
|
<td>{check.status}</td>
|
|
<td>
|
|
{(check.evidencePages ?? []).join(", ") || "n/a"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</SummarySubsection>
|
|
)}
|
|
|
|
{isTreeStatementMode ? (
|
|
<SummarySubsection title="Normalization">
|
|
<NormalizationSummary
|
|
normalization={financials.normalization}
|
|
showHeader={false}
|
|
/>
|
|
</SummarySubsection>
|
|
) : null}
|
|
</div>
|
|
</CollapsibleSection>
|
|
) : null}
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
export default function FinancialsPage() {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">
|
|
Loading financial terminal...
|
|
</div>
|
|
}
|
|
>
|
|
<FinancialsPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|