1294 lines
47 KiB
TypeScript
1294 lines
47 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 {
|
|
Bar,
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis
|
|
} from 'recharts';
|
|
import {
|
|
AlertTriangle,
|
|
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 {
|
|
FinancialControlBar,
|
|
type FinancialControlAction,
|
|
type FinancialControlOption,
|
|
type FinancialControlSection
|
|
} from '@/components/financials/control-bar';
|
|
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 { queueFilingSync } from '@/lib/api';
|
|
import {
|
|
formatCurrencyByScale,
|
|
formatFinancialStatementValue,
|
|
formatPercent,
|
|
type NumberScaleUnit
|
|
} from '@/lib/format';
|
|
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
|
import {
|
|
buildStatementTree,
|
|
resolveStatementSelection,
|
|
type StatementInspectorSelection
|
|
} from '@/lib/financials/statement-view-model';
|
|
import { queryKeys } from '@/lib/query/keys';
|
|
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
|
import type {
|
|
CompanyFinancialStatementsResponse,
|
|
DetailFinancialRow,
|
|
DerivedFinancialRow,
|
|
FinancialCadence,
|
|
FinancialDisplayMode,
|
|
FinancialSurfaceKind,
|
|
FinancialUnit,
|
|
RatioRow,
|
|
StandardizedFinancialRow,
|
|
StructuredKpiRow,
|
|
SurfaceDetailMap,
|
|
SurfaceFinancialRow,
|
|
TaxonomyStatementRow,
|
|
TrendSeries
|
|
} 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: FinancialControlOption[] = [
|
|
{ value: 'income_statement', label: 'Income Statement' },
|
|
{ value: 'balance_sheet', label: 'Balance Sheet' },
|
|
{ value: 'cash_flow_statement', label: 'Cash Flow Statement' },
|
|
{ value: 'ratios', label: 'Ratios' },
|
|
{ value: 'segments_kpis', label: 'Segments & KPIs' },
|
|
{ value: 'adjusted', label: 'Adjusted' },
|
|
{ value: 'custom_metrics', label: 'Custom Metrics' }
|
|
];
|
|
|
|
const CADENCE_OPTIONS: FinancialControlOption[] = [
|
|
{ value: 'annual', label: 'Annual' },
|
|
{ value: 'quarterly', label: 'Quarterly' },
|
|
{ value: 'ltm', label: 'LTM' }
|
|
];
|
|
|
|
const DISPLAY_MODE_OPTIONS: FinancialControlOption[] = [
|
|
{ value: 'standardized', label: 'Standardized' },
|
|
{ value: 'faithful', label: 'Filing-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';
|
|
}
|
|
|
|
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'>;
|
|
}) {
|
|
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 mergeDetailMaps(base: SurfaceDetailMap | null, next: SurfaceDetailMap | null) {
|
|
if (!base) {
|
|
return next;
|
|
}
|
|
|
|
if (!next) {
|
|
return base;
|
|
}
|
|
|
|
const merged: SurfaceDetailMap = structuredClone(base);
|
|
|
|
for (const [surfaceKey, detailRows] of Object.entries(next)) {
|
|
const existingRows = merged[surfaceKey] ?? [];
|
|
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
|
|
|
|
for (const detailRow of detailRows) {
|
|
const existing = rowMap.get(detailRow.key);
|
|
if (!existing) {
|
|
rowMap.set(detailRow.key, structuredClone(detailRow));
|
|
continue;
|
|
}
|
|
|
|
existing.values = {
|
|
...existing.values,
|
|
...detailRow.values
|
|
};
|
|
existing.sourceFactIds = [...new Set([...existing.sourceFactIds, ...detailRow.sourceFactIds])];
|
|
existing.dimensionsSummary = [...new Set([...existing.dimensionsSummary, ...detailRow.dimensionsSummary])];
|
|
}
|
|
|
|
merged[surfaceKey] = [...rowMap.values()];
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function mergeFinancialPages(
|
|
base: CompanyFinancialStatementsResponse | null,
|
|
next: CompanyFinancialStatementsResponse
|
|
) {
|
|
if (!base) {
|
|
return next;
|
|
}
|
|
|
|
const periods = [...base.periods, ...next.periods]
|
|
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
|
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
|
|
|
const mergeRows = <T extends { key: string; values: Record<string, number | null> }>(rows: T[]) => {
|
|
const map = new Map<string, T>();
|
|
for (const row of rows) {
|
|
const existing = map.get(row.key);
|
|
if (!existing) {
|
|
map.set(row.key, structuredClone(row));
|
|
continue;
|
|
}
|
|
|
|
existing.values = {
|
|
...existing.values,
|
|
...row.values
|
|
};
|
|
}
|
|
|
|
return [...map.values()];
|
|
};
|
|
|
|
return {
|
|
...next,
|
|
periods,
|
|
statementRows: next.statementRows && base.statementRows
|
|
? {
|
|
faithful: mergeRows([...base.statementRows.faithful, ...next.statementRows.faithful]),
|
|
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
|
|
}
|
|
: next.statementRows,
|
|
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
|
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
|
|
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
|
|
trendSeries: next.trendSeries,
|
|
categories: next.categories,
|
|
dimensionBreakdown: next.dimensionBreakdown ?? base.dimensionBreakdown
|
|
};
|
|
}
|
|
|
|
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 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 [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 [loadingMore, setLoadingMore] = useState(false);
|
|
const [syncingFinancials, setSyncingFinancials] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
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';
|
|
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]);
|
|
|
|
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');
|
|
|
|
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: 12
|
|
}));
|
|
|
|
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, 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 isTreeStatementMode = displayMode === 'standardized' && isStatementSurfaceKind(surfaceKind);
|
|
|
|
const activeRows = useMemo<FlatDisplayRow[]>(() => {
|
|
if (!financials) {
|
|
return [];
|
|
}
|
|
|
|
switch (surfaceKind) {
|
|
case 'income_statement':
|
|
case 'balance_sheet':
|
|
case 'cash_flow_statement':
|
|
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') ?? null;
|
|
}
|
|
|
|
if (surfaceKind === 'balance_sheet') {
|
|
return standardizedRows.find((row) => row.key === 'total_assets') ?? 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 periods.map((period) => ({
|
|
label: formatLongDate(period.periodEnd ?? period.filingDate),
|
|
...Object.fromEntries(trendSeries.map((series) => [series.key, series.values[period.id] ?? null]))
|
|
}));
|
|
}, [periods, trendSeries]);
|
|
|
|
const controlSections = useMemo<FinancialControlSection[]>(() => {
|
|
const sections: FinancialControlSection[] = [
|
|
{
|
|
id: 'surface',
|
|
label: 'Surface',
|
|
value: surfaceKind,
|
|
options: SURFACE_OPTIONS,
|
|
onChange: (value) => {
|
|
setSurfaceKind(value as FinancialSurfaceKind);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
}
|
|
},
|
|
{
|
|
id: 'cadence',
|
|
label: 'Cadence',
|
|
value: cadence,
|
|
options: CADENCE_OPTIONS,
|
|
onChange: (value) => {
|
|
setCadence(value as FinancialCadence);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
}
|
|
}
|
|
];
|
|
|
|
if (surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') {
|
|
sections.push({
|
|
id: 'display',
|
|
label: 'Display',
|
|
value: displayMode,
|
|
options: DISPLAY_MODE_OPTIONS,
|
|
onChange: (value) => {
|
|
setDisplayMode(value as FinancialDisplayMode);
|
|
setSelectedFlatRowKey(null);
|
|
setSelectedRowRef(null);
|
|
setExpandedRowKeys(new Set());
|
|
}
|
|
});
|
|
}
|
|
|
|
sections.push({
|
|
id: 'scale',
|
|
label: 'Scale',
|
|
value: valueScale,
|
|
options: FINANCIAL_VALUE_SCALE_OPTIONS,
|
|
onChange: (value) => setValueScale(value as NumberScaleUnit)
|
|
});
|
|
|
|
return sections;
|
|
}, [cadence, displayMode, surfaceKind, valueScale]);
|
|
|
|
const controlActions = useMemo<FinancialControlAction[]>(() => {
|
|
const actions: FinancialControlAction[] = [];
|
|
|
|
if ((surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement' || surfaceKind === 'ratios') && displayMode !== 'faithful') {
|
|
actions.push({
|
|
id: 'percent-change',
|
|
label: showPercentChange ? '% Change On' : '% Change',
|
|
variant: showPercentChange ? 'primary' : 'secondary',
|
|
onClick: () => {
|
|
setShowPercentChange((current) => !current);
|
|
setShowCommonSize(false);
|
|
}
|
|
});
|
|
|
|
actions.push({
|
|
id: 'common-size',
|
|
label: showCommonSize ? 'Common Size On' : 'Common Size',
|
|
variant: showCommonSize ? 'primary' : 'secondary',
|
|
onClick: () => {
|
|
setShowCommonSize((current) => !current);
|
|
setShowPercentChange(false);
|
|
},
|
|
disabled: surfaceKind === 'ratios' && selectedRow !== null && isDerivedRow(selectedRow) && selectedRow.unit !== 'percent'
|
|
});
|
|
}
|
|
|
|
if (financials?.nextCursor) {
|
|
actions.push({
|
|
id: 'load-older',
|
|
label: loadingMore ? 'Loading Older...' : 'Load Older',
|
|
variant: 'secondary',
|
|
disabled: loadingMore,
|
|
onClick: () => {
|
|
if (!financials.nextCursor) {
|
|
return;
|
|
}
|
|
|
|
void loadFinancials(ticker, {
|
|
cursor: financials.nextCursor,
|
|
append: true
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
}, [displayMode, financials?.nextCursor, loadFinancials, loadingMore, selectedRow, showCommonSize, showPercentChange, surfaceKind, ticker]);
|
|
|
|
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="Surface-based filing financials with 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"
|
|
disabled={syncingFinancials}
|
|
onClick={() => {
|
|
void syncFinancials();
|
|
}}
|
|
>
|
|
<Download className="size-4" />
|
|
{syncingFinancials ? 'Syncing...' : 'Sync Financials'}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
void loadFinancials(ticker);
|
|
}}
|
|
>
|
|
<RefreshCcw className="size-4" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
)}
|
|
>
|
|
<Panel title="Company Selector" subtitle="Load one ticker across statements, ratios, and KPI time series.">
|
|
<form
|
|
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const normalized = tickerInput.trim().toUpperCase();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
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"
|
|
/>
|
|
<Button type="submit" className="w-full sm:w-auto">
|
|
<Search className="size-4" />
|
|
Load Financials
|
|
</Button>
|
|
{financials ? (
|
|
<>
|
|
<Link
|
|
href={`/analysis?ticker=${financials.company.ticker}`}
|
|
onMouseEnter={() => prefetchResearchTicker(financials.company.ticker)}
|
|
onFocus={() => prefetchResearchTicker(financials.company.ticker)}
|
|
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
|
>
|
|
Open overview
|
|
</Link>
|
|
<Link
|
|
href={buildGraphingHref(financials.company.ticker)}
|
|
onMouseEnter={() => prefetchResearchTicker(financials.company.ticker)}
|
|
onFocus={() => prefetchResearchTicker(financials.company.ticker)}
|
|
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
|
>
|
|
Open graphing
|
|
</Link>
|
|
</>
|
|
) : null}
|
|
</form>
|
|
</Panel>
|
|
|
|
{error ? (
|
|
<Panel variant="surface">
|
|
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
|
</Panel>
|
|
) : null}
|
|
|
|
<FinancialControlBar
|
|
title="Financial Controls"
|
|
subtitle="Choose surface, cadence, display mode, and presentation toggles."
|
|
sections={controlSections}
|
|
actions={controlActions}
|
|
/>
|
|
|
|
<Panel title="Search & Filters" subtitle="Filter rows without mutating the source data.">
|
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<Input
|
|
value={rowSearch}
|
|
onChange={(event) => setRowSearch(event.target.value)}
|
|
placeholder="Search rows by label"
|
|
className="w-full sm:max-w-sm"
|
|
/>
|
|
<span className="text-sm text-[color:var(--terminal-muted)]">
|
|
{rowResultCountLabel}
|
|
</span>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Trend Chart" subtitle="Default trend series for the selected surface." variant="surface">
|
|
{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>
|
|
|
|
{financials && isTreeStatementMode ? (
|
|
<NormalizationSummary normalization={financials.normalization} />
|
|
) : null}
|
|
|
|
<Panel title="Surface Matrix" subtitle="Standardized statements, ratios, and KPIs render in one shared matrix." variant="surface">
|
|
{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>
|
|
) : periods.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-3">
|
|
{isStatementSurfaceKind(surfaceKind) ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
|
USD · {valueScaleLabel}
|
|
</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={periods}
|
|
sections={treeModel.sections}
|
|
selectedRowRef={selectedRowRef}
|
|
onToggleRow={toggleExpandedRow}
|
|
onSelectRow={setSelectedRowRef}
|
|
renderCellValue={renderStatementTreeCellValue}
|
|
periodLabelFormatter={formatLongDate}
|
|
/>
|
|
) : (
|
|
<div className="data-table-wrap">
|
|
<table className="data-table min-w-[980px]">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
|
|
{periods.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={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
|
|
</tr>
|
|
) : null}
|
|
{group.rows.map((row) => (
|
|
<tr
|
|
key={row.key}
|
|
className={selectedFlatRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
|
onClick={() => setSelectedFlatRowKey(row.key)}
|
|
>
|
|
<td className="sticky left-0 z-10 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>
|
|
{periods.map((period, index) => (
|
|
<td key={`${row.key}-${period.id}`}>
|
|
{buildDisplayValue({
|
|
row,
|
|
periodId: period.id,
|
|
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
|
|
commonSizeRow,
|
|
displayMode,
|
|
showPercentChange,
|
|
showCommonSize,
|
|
scale: valueScale,
|
|
surfaceKind
|
|
})}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
{isTreeStatementMode && isStatementSurfaceKind(surfaceKind) ? (
|
|
<StatementRowInspector
|
|
selection={selectedStatementRow}
|
|
dimensionRows={dimensionRows}
|
|
periods={periods}
|
|
surfaceKind={surfaceKind}
|
|
renderValue={renderStatementTreeCellValue}
|
|
renderDimensionValue={renderDimensionValue}
|
|
/>
|
|
) : (
|
|
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
|
|
{!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-3 md:grid-cols-2">
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
|
<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-3 py-2">
|
|
<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-3 py-2">
|
|
<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-3 md:grid-cols-2">
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
|
<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-3 py-2">
|
|
<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-3 py-2">
|
|
<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>{periods.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>
|
|
)}
|
|
|
|
{(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? (
|
|
<Panel title="Metric Validation" subtitle="Validation remains limited to statement-derived taxonomy metrics in v1." variant="surface">
|
|
<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>
|
|
)}
|
|
</Panel>
|
|
) : 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>
|
|
);
|
|
}
|