851 lines
31 KiB
TypeScript
851 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import Link from 'next/link';
|
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { format } from 'date-fns';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import {
|
|
Area,
|
|
AreaChart,
|
|
Bar,
|
|
BarChart,
|
|
CartesianGrid,
|
|
Line,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis
|
|
} from 'recharts';
|
|
import { ChartNoAxesCombined, ChevronDown, RefreshCcw, Search } from 'lucide-react';
|
|
import { AppShell } from '@/components/shell/app-shell';
|
|
import { MetricCard } from '@/components/dashboard/metric-card';
|
|
import {
|
|
FinancialControlBar,
|
|
type FinancialControlAction,
|
|
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 {
|
|
formatCurrencyByScale,
|
|
formatPercent,
|
|
type NumberScaleUnit
|
|
} from '@/lib/format';
|
|
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
|
import type {
|
|
CompanyFinancialStatementsResponse,
|
|
DimensionBreakdownRow,
|
|
FilingFaithfulStatementRow,
|
|
FinancialHistoryWindow,
|
|
FinancialStatementKind,
|
|
FinancialStatementMode,
|
|
StandardizedStatementRow
|
|
} from '@/lib/types';
|
|
|
|
type LoadOptions = {
|
|
cursor?: string | null;
|
|
append?: boolean;
|
|
};
|
|
|
|
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 MODE_OPTIONS: Array<{ value: FinancialStatementMode; label: string }> = [
|
|
{ value: 'standardized', label: 'Standardized' },
|
|
{ value: 'filing_faithful', label: 'Filing-faithful' }
|
|
];
|
|
|
|
const STATEMENT_OPTIONS: Array<{ value: FinancialStatementKind; label: string }> = [
|
|
{ value: 'income', label: 'Income' },
|
|
{ value: 'balance', label: 'Balance Sheet' },
|
|
{ value: 'cash_flow', label: 'Cash Flow' },
|
|
{ value: 'equity', label: 'Equity' },
|
|
{ value: 'comprehensive_income', label: 'Comprehensive Income' }
|
|
];
|
|
|
|
const WINDOW_OPTIONS: Array<{ value: FinancialHistoryWindow; label: string }> = [
|
|
{ value: '10y', label: '10 Years' },
|
|
{ value: 'all', label: 'Full Available' }
|
|
];
|
|
|
|
const CHART_MUTED = '#b4ced9';
|
|
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
|
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
|
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
|
|
|
type OverviewPoint = {
|
|
periodId: string;
|
|
filingDate: string;
|
|
label: string;
|
|
revenue: number | null;
|
|
netIncome: number | null;
|
|
totalAssets: number | null;
|
|
cash: number | null;
|
|
debt: number | null;
|
|
};
|
|
|
|
function formatLongDate(value: string) {
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
return format(parsed, 'MMM dd, yyyy');
|
|
}
|
|
|
|
function formatShortDate(value: string) {
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
return format(parsed, 'MMM yyyy');
|
|
}
|
|
|
|
function asDisplayCurrency(value: number | null, scale: NumberScaleUnit) {
|
|
return value === null ? 'n/a' : formatCurrencyByScale(value, scale);
|
|
}
|
|
|
|
function asAxisCurrencyTick(value: number, scale: NumberScaleUnit) {
|
|
return formatCurrencyByScale(value, scale, { maximumFractionDigits: 1 });
|
|
}
|
|
|
|
function asTooltipCurrency(value: unknown, scale: NumberScaleUnit) {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
return 'n/a';
|
|
}
|
|
|
|
return formatCurrencyByScale(numeric, scale);
|
|
}
|
|
|
|
function ratioPercent(numerator: number | null, denominator: number | null) {
|
|
if (numerator === null || denominator === null || denominator === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (numerator / denominator) * 100;
|
|
}
|
|
|
|
function toStandardizedRows(data: CompanyFinancialStatementsResponse | null) {
|
|
if (!data || data.mode !== 'standardized') {
|
|
return [];
|
|
}
|
|
|
|
return data.rows as StandardizedStatementRow[];
|
|
}
|
|
|
|
function toFilingFaithfulRows(data: CompanyFinancialStatementsResponse | null) {
|
|
if (!data || data.mode !== 'filing_faithful') {
|
|
return [];
|
|
}
|
|
|
|
return data.rows as FilingFaithfulStatementRow[];
|
|
}
|
|
|
|
function rowValue(row: { values: Record<string, number | null> }, periodId: string) {
|
|
return periodId in row.values ? row.values[periodId] : null;
|
|
}
|
|
|
|
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((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
|
|
|
const rowMap = new Map<string, StandardizedStatementRow | FilingFaithfulStatementRow>();
|
|
|
|
for (const row of [...base.rows, ...next.rows]) {
|
|
const existing = rowMap.get(row.key);
|
|
if (!existing) {
|
|
rowMap.set(row.key, {
|
|
...row,
|
|
values: { ...row.values }
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
|
|
for (const [periodId, value] of Object.entries(row.values)) {
|
|
if (!(periodId in existing.values)) {
|
|
existing.values[periodId] = value;
|
|
}
|
|
}
|
|
|
|
if ('sourceConcepts' in existing && 'sourceConcepts' in row) {
|
|
for (const concept of row.sourceConcepts) {
|
|
if (!existing.sourceConcepts.includes(concept)) {
|
|
existing.sourceConcepts.push(concept);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const dimensionBreakdown = (() => {
|
|
if (!base.dimensionBreakdown && !next.dimensionBreakdown) {
|
|
return null;
|
|
}
|
|
|
|
const map = new Map<string, DimensionBreakdownRow[]>();
|
|
for (const source of [base.dimensionBreakdown, next.dimensionBreakdown]) {
|
|
if (!source) {
|
|
continue;
|
|
}
|
|
|
|
for (const [key, rows] of Object.entries(source)) {
|
|
const existing = map.get(key);
|
|
if (existing) {
|
|
existing.push(...rows);
|
|
} else {
|
|
map.set(key, [...rows]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.fromEntries(map.entries());
|
|
})();
|
|
|
|
const mergedRows = [...rowMap.values()];
|
|
|
|
return {
|
|
...next,
|
|
periods,
|
|
rows: next.mode === 'standardized'
|
|
? mergedRows as StandardizedStatementRow[]
|
|
: mergedRows as FilingFaithfulStatementRow[],
|
|
nextCursor: next.nextCursor,
|
|
coverage: {
|
|
filings: periods.length,
|
|
rows: rowMap.size,
|
|
dimensions: dimensionBreakdown
|
|
? Object.values(dimensionBreakdown).reduce((total, rows) => total + rows.length, 0)
|
|
: 0
|
|
},
|
|
dataSourceStatus: {
|
|
...next.dataSourceStatus,
|
|
queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync
|
|
},
|
|
dimensionBreakdown
|
|
};
|
|
}
|
|
|
|
function findStandardizedRowValue(
|
|
data: CompanyFinancialStatementsResponse | null,
|
|
preferredKey: string,
|
|
fallbackIncludes: string[]
|
|
) {
|
|
const rows = toStandardizedRows(data);
|
|
const exact = rows.find((row) => row.key === preferredKey);
|
|
if (exact) {
|
|
return exact;
|
|
}
|
|
|
|
return rows.find((row) => {
|
|
const haystack = `${row.key} ${row.label} ${row.concept}`.toLowerCase();
|
|
return fallbackIncludes.some((needle) => haystack.includes(needle));
|
|
}) ?? null;
|
|
}
|
|
|
|
function buildOverviewSeries(
|
|
incomeData: CompanyFinancialStatementsResponse | null,
|
|
balanceData: CompanyFinancialStatementsResponse | null
|
|
): OverviewPoint[] {
|
|
const periodMap = new Map<string, { filingDate: string }>();
|
|
|
|
for (const source of [incomeData, balanceData]) {
|
|
for (const period of source?.periods ?? []) {
|
|
periodMap.set(period.id, { filingDate: period.filingDate });
|
|
}
|
|
}
|
|
|
|
const periods = [...periodMap.entries()]
|
|
.map(([periodId, data]) => ({ periodId, filingDate: data.filingDate }))
|
|
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
|
|
|
const revenueRow = findStandardizedRowValue(incomeData, 'revenue', ['revenue', 'sales']);
|
|
const netIncomeRow = findStandardizedRowValue(incomeData, 'net-income', ['net income', 'profit']);
|
|
const assetsRow = findStandardizedRowValue(balanceData, 'total-assets', ['total assets']);
|
|
const cashRow = findStandardizedRowValue(balanceData, 'cash-and-equivalents', ['cash']);
|
|
const debtRow = findStandardizedRowValue(balanceData, 'total-debt', ['debt', 'borrowings']);
|
|
|
|
return periods.map((period) => ({
|
|
periodId: period.periodId,
|
|
filingDate: period.filingDate,
|
|
label: formatShortDate(period.filingDate),
|
|
revenue: revenueRow ? rowValue(revenueRow, period.periodId) : null,
|
|
netIncome: netIncomeRow ? rowValue(netIncomeRow, period.periodId) : null,
|
|
totalAssets: assetsRow ? rowValue(assetsRow, period.periodId) : null,
|
|
cash: cashRow ? rowValue(cashRow, period.periodId) : null,
|
|
debt: debtRow ? rowValue(debtRow, period.periodId) : null
|
|
}));
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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 [mode, setMode] = useState<FinancialStatementMode>('standardized');
|
|
const [statement, setStatement] = useState<FinancialStatementKind>('income');
|
|
const [window, setWindow] = useState<FinancialHistoryWindow>('10y');
|
|
const [valueScale, setValueScale] = useState<NumberScaleUnit>('millions');
|
|
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
|
|
const [overviewIncome, setOverviewIncome] = useState<CompanyFinancialStatementsResponse | null>(null);
|
|
const [overviewBalance, setOverviewBalance] = useState<CompanyFinancialStatementsResponse | null>(null);
|
|
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
|
|
const [dimensionsEnabled, setDimensionsEnabled] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = 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);
|
|
}, [searchParams]);
|
|
|
|
const loadOverview = useCallback(async (symbol: string, selectedWindow: FinancialHistoryWindow) => {
|
|
const [incomeResponse, balanceResponse] = await Promise.all([
|
|
queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
|
|
ticker: symbol,
|
|
mode: 'standardized',
|
|
statement: 'income',
|
|
window: selectedWindow,
|
|
includeDimensions: false,
|
|
limit: selectedWindow === 'all' ? 120 : 80
|
|
})),
|
|
queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
|
|
ticker: symbol,
|
|
mode: 'standardized',
|
|
statement: 'balance',
|
|
window: selectedWindow,
|
|
includeDimensions: false,
|
|
limit: selectedWindow === 'all' ? 120 : 80
|
|
}))
|
|
]);
|
|
|
|
setOverviewIncome(incomeResponse.financials);
|
|
setOverviewBalance(balanceResponse.financials);
|
|
}, [queryClient]);
|
|
|
|
const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => {
|
|
const normalizedTicker = symbol.trim().toUpperCase();
|
|
const nextCursor = options?.cursor ?? null;
|
|
const includeDimensions = dimensionsEnabled || selectedRowKey !== null;
|
|
|
|
if (!options?.append) {
|
|
setLoading(true);
|
|
} else {
|
|
setLoadingMore(true);
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
|
|
ticker: normalizedTicker,
|
|
mode,
|
|
statement,
|
|
window,
|
|
includeDimensions,
|
|
cursor: nextCursor,
|
|
limit: window === 'all' ? 60 : 80
|
|
}));
|
|
|
|
setFinancials((current) => {
|
|
if (options?.append) {
|
|
return mergeFinancialPages(current, response.financials);
|
|
}
|
|
|
|
return response.financials;
|
|
});
|
|
|
|
await loadOverview(normalizedTicker, window);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Unable to load financial history');
|
|
if (!options?.append) {
|
|
setFinancials(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
}, [
|
|
queryClient,
|
|
mode,
|
|
statement,
|
|
window,
|
|
dimensionsEnabled,
|
|
selectedRowKey,
|
|
loadOverview
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!isPending && isAuthenticated) {
|
|
void loadFinancials(ticker);
|
|
}
|
|
}, [isPending, isAuthenticated, ticker, mode, statement, window, dimensionsEnabled, loadFinancials]);
|
|
|
|
const periods = useMemo(() => {
|
|
return [...(financials?.periods ?? [])]
|
|
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
|
}, [financials?.periods]);
|
|
|
|
const standardizedRows = useMemo(() => toStandardizedRows(financials), [financials]);
|
|
const filingFaithfulRows = useMemo(() => toFilingFaithfulRows(financials), [financials]);
|
|
|
|
const statementRows = mode === 'standardized'
|
|
? standardizedRows
|
|
: filingFaithfulRows;
|
|
|
|
const overviewSeries = useMemo(() => {
|
|
return buildOverviewSeries(overviewIncome, overviewBalance);
|
|
}, [overviewIncome, overviewBalance]);
|
|
|
|
const latestOverview = overviewSeries[overviewSeries.length - 1] ?? null;
|
|
|
|
const selectedRow = useMemo(() => {
|
|
if (!selectedRowKey) {
|
|
return null;
|
|
}
|
|
|
|
return statementRows.find((row) => row.key === selectedRowKey) ?? null;
|
|
}, [selectedRowKey, statementRows]);
|
|
|
|
const dimensionRows = useMemo(() => {
|
|
if (!selectedRow || !financials?.dimensionBreakdown) {
|
|
return [];
|
|
}
|
|
|
|
const direct = financials.dimensionBreakdown[selectedRow.key] ?? [];
|
|
if (direct.length > 0) {
|
|
return direct;
|
|
}
|
|
|
|
if ('concept' in selectedRow && selectedRow.concept) {
|
|
const conceptKey = selectedRow.concept.toLowerCase();
|
|
for (const rows of Object.values(financials.dimensionBreakdown)) {
|
|
const matched = rows.filter((row) => (row.concept ?? '').toLowerCase() === conceptKey);
|
|
if (matched.length > 0) {
|
|
return matched;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}, [selectedRow, financials?.dimensionBreakdown]);
|
|
|
|
const selectedScaleLabel = useMemo(() => {
|
|
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? 'Millions (M)';
|
|
}, [valueScale]);
|
|
|
|
const controlSections = useMemo<FinancialControlSection[]>(() => [
|
|
{
|
|
id: 'mode',
|
|
label: 'Mode',
|
|
value: mode,
|
|
options: MODE_OPTIONS,
|
|
onChange: (nextValue) => {
|
|
setMode(nextValue as FinancialStatementMode);
|
|
setSelectedRowKey(null);
|
|
}
|
|
},
|
|
{
|
|
id: 'statement',
|
|
label: 'Statement',
|
|
value: statement,
|
|
options: STATEMENT_OPTIONS,
|
|
onChange: (nextValue) => {
|
|
setStatement(nextValue as FinancialStatementKind);
|
|
setSelectedRowKey(null);
|
|
}
|
|
},
|
|
{
|
|
id: 'history',
|
|
label: 'Window',
|
|
value: window,
|
|
options: WINDOW_OPTIONS,
|
|
onChange: (nextValue) => {
|
|
setWindow(nextValue as FinancialHistoryWindow);
|
|
setSelectedRowKey(null);
|
|
}
|
|
},
|
|
{
|
|
id: 'scale',
|
|
label: 'Scale',
|
|
value: valueScale,
|
|
options: FINANCIAL_VALUE_SCALE_OPTIONS,
|
|
onChange: (nextValue) => setValueScale(nextValue as NumberScaleUnit)
|
|
}
|
|
], [mode, statement, window, valueScale]);
|
|
|
|
const controlActions = useMemo<FinancialControlAction[]>(() => {
|
|
const actions: FinancialControlAction[] = [];
|
|
|
|
if (window === '10y') {
|
|
actions.push({
|
|
id: 'load-full-history',
|
|
label: 'Load Full History',
|
|
variant: 'secondary',
|
|
onClick: () => {
|
|
setWindow('all');
|
|
setSelectedRowKey(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (window === 'all' && financials?.nextCursor) {
|
|
actions.push({
|
|
id: 'load-older-periods',
|
|
label: loadingMore ? 'Loading Older...' : 'Load Older Periods',
|
|
variant: 'secondary',
|
|
disabled: loadingMore,
|
|
onClick: () => {
|
|
if (!financials.nextCursor) {
|
|
return;
|
|
}
|
|
|
|
void loadFinancials(ticker, {
|
|
cursor: financials.nextCursor,
|
|
append: true
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
}, [window, financials?.nextCursor, loadingMore, loadFinancials, ticker]);
|
|
|
|
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="Dual-mode financial statements with standardized comparability and filing-faithful presentation."
|
|
activeTicker={financials?.company.ticker ?? ticker}
|
|
actions={(
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
void loadFinancials(ticker);
|
|
}}
|
|
>
|
|
<RefreshCcw className="size-4" />
|
|
Refresh
|
|
</Button>
|
|
)}
|
|
>
|
|
<Panel title="Company Selector" subtitle="Load statement history by ticker. Default window is 10 years; full history is on demand.">
|
|
<form
|
|
className="flex flex-wrap items-center gap-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const normalized = tickerInput.trim().toUpperCase();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
setTicker(normalized);
|
|
}}
|
|
>
|
|
<Input
|
|
value={tickerInput}
|
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
|
placeholder="Ticker (AAPL)"
|
|
className="max-w-xs"
|
|
/>
|
|
<Button type="submit">
|
|
<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 analysis
|
|
</Link>
|
|
) : null}
|
|
</form>
|
|
</Panel>
|
|
|
|
{error ? (
|
|
<Panel>
|
|
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
|
</Panel>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard
|
|
label="Latest Revenue"
|
|
value={latestOverview ? asDisplayCurrency(latestOverview.revenue, valueScale) : 'n/a'}
|
|
delta={latestOverview ? formatLongDate(latestOverview.filingDate) : 'No standardized history'}
|
|
/>
|
|
<MetricCard
|
|
label="Latest Net Income"
|
|
value={latestOverview ? asDisplayCurrency(latestOverview.netIncome, valueScale) : 'n/a'}
|
|
delta={latestOverview ? `Net margin ${formatPercent(ratioPercent(latestOverview.netIncome, latestOverview.revenue) ?? 0)}` : 'No standardized history'}
|
|
positive={(latestOverview?.netIncome ?? 0) >= 0}
|
|
/>
|
|
<MetricCard
|
|
label="Latest Total Assets"
|
|
value={latestOverview ? asDisplayCurrency(latestOverview.totalAssets, valueScale) : 'n/a'}
|
|
delta={latestOverview ? `Debt ${asDisplayCurrency(latestOverview.debt, valueScale)}` : 'No standardized history'}
|
|
/>
|
|
<MetricCard
|
|
label="Cash / Debt"
|
|
value={latestOverview && latestOverview.cash !== null && latestOverview.debt !== null && latestOverview.debt !== 0
|
|
? `${(latestOverview.cash / latestOverview.debt).toFixed(2)}x`
|
|
: 'n/a'}
|
|
delta={latestOverview ? `Cash ${asDisplayCurrency(latestOverview.cash, valueScale)}` : 'No standardized history'}
|
|
/>
|
|
</div>
|
|
|
|
<FinancialControlBar
|
|
title="Financial Controls"
|
|
subtitle={`Compact multi-filter control bar. Current display scale: ${selectedScaleLabel}.`}
|
|
sections={controlSections}
|
|
actions={controlActions}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
|
<Panel title="Standardized Income Trend" subtitle="Overview chart remains anchored to standardized rows.">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading overview chart...</p>
|
|
) : overviewSeries.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No standardized income history available yet.</p>
|
|
) : (
|
|
<div className="h-[320px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={overviewSeries}>
|
|
<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) => asAxisCurrencyTick(value, valueScale)}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
|
contentStyle={{
|
|
backgroundColor: CHART_TOOLTIP_BG,
|
|
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
|
borderRadius: '0.75rem'
|
|
}}
|
|
/>
|
|
<Bar dataKey="revenue" name="Revenue" fill="#68ffd5" radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey="netIncome" name="Net Income" fill="#5fd3ff" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
<Panel title="Standardized Balance Trend" subtitle="Assets, cash, and debt from standardized balance rows.">
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading balance chart...</p>
|
|
) : overviewSeries.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No standardized balance history available yet.</p>
|
|
) : (
|
|
<div className="h-[320px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={overviewSeries}>
|
|
<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) => asAxisCurrencyTick(value, valueScale)}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
|
contentStyle={{
|
|
backgroundColor: CHART_TOOLTIP_BG,
|
|
border: `1px solid ${CHART_TOOLTIP_BORDER}`,
|
|
borderRadius: '0.75rem'
|
|
}}
|
|
/>
|
|
<Area type="monotone" dataKey="totalAssets" name="Total Assets" stroke="#68ffd5" fill="rgba(104,255,213,0.18)" strokeWidth={2} />
|
|
<Line type="monotone" dataKey="cash" name="Cash" stroke="#8bd3ff" strokeWidth={2} dot={false} />
|
|
<Line type="monotone" dataKey="debt" name="Debt" stroke="#ffd08a" strokeWidth={2} dot={false} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
|
|
<Panel
|
|
title="Statement Matrix"
|
|
subtitle={`${mode === 'standardized' ? 'Cross-company comparable' : 'Filing-native'} ${statement.replace('_', ' ')} rows by period. Click rows with available dimensions for drill-down.`}
|
|
>
|
|
{loading ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading statement matrix...</p>
|
|
) : periods.length === 0 || statementRows.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No statement rows available for the selected filters yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<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.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>
|
|
{statementRows.map((row) => (
|
|
<tr
|
|
key={row.key}
|
|
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
|
onClick={() => {
|
|
setSelectedRowKey(row.key);
|
|
if (row.hasDimensions && !dimensionsEnabled) {
|
|
setDimensionsEnabled(true);
|
|
}
|
|
}}
|
|
>
|
|
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
|
{'depth' in row ? (
|
|
<div className="flex items-center gap-2">
|
|
<span style={{ paddingLeft: `${Math.min(row.depth, 10) * 12}px` }}>{row.label}</span>
|
|
{row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span>{row.label}</span>
|
|
{row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
|
</div>
|
|
)}
|
|
</td>
|
|
{periods.map((period) => (
|
|
<td key={`${row.key}-${period.id}`}>
|
|
{asDisplayCurrency(rowValue(row, period.id), valueScale)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
</Panel>
|
|
|
|
<Panel title="Dimension Drill-down" subtitle="Segment/geography/product axes are shown only for the selected row when available.">
|
|
{!selectedRow ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Select a statement row to inspect dimensional facts.</p>
|
|
) : !selectedRow.hasDimensions ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional data is available for {selectedRow.label}.</p>
|
|
) : !dimensionsEnabled ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Enable dimensions by selecting the row again.</p>
|
|
) : dimensionRows.length === 0 ? (
|
|
<p className="text-sm text-[color:var(--terminal-muted)]">Dimensions are still loading or unavailable for this row.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<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) => {
|
|
const period = periods.find((item) => item.id === row.periodId);
|
|
return (
|
|
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
|
<td>{period ? formatLongDate(period.filingDate) : row.periodId}</td>
|
|
<td>{row.axis}</td>
|
|
<td>{row.member}</td>
|
|
<td>{asDisplayCurrency(row.value, valueScale)}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
{financials ? (
|
|
<Panel title="Data Source Status" subtitle="Hydration and parsing status for filing statement snapshots.">
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
|
<p className="text-[color:var(--terminal-muted)]">Hydrated</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{financials.dataSourceStatus.hydratedFilings}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
|
<p className="text-[color:var(--terminal-muted)]">Partial</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{financials.dataSourceStatus.partialFilings}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
|
<p className="text-[color:var(--terminal-muted)]">Failed</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{financials.dataSourceStatus.failedFilings}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
|
<p className="text-[color:var(--terminal-muted)]">Pending</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{financials.dataSourceStatus.pendingFilings}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
|
<p className="text-[color:var(--terminal-muted)]">Background Sync</p>
|
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{financials.dataSourceStatus.queuedSync ? 'Queued' : 'Idle'}</p>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
) : null}
|
|
|
|
<Panel>
|
|
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
|
<ChartNoAxesCombined className="size-4" />
|
|
Financial Statements V2: standardized + filing-faithful history
|
|
</div>
|
|
</Panel>
|
|
</AppShell>
|
|
);
|
|
}
|