mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-26 08:48:05 +00:00
160 lines
6.6 KiB
TypeScript
160 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { ErrorBanner } from "../ui/ErrorBanner";
|
|
import { LoadingPulse } from "../ui/LoadingPulse";
|
|
import { StatCard } from "../ui/StatCard";
|
|
import { flags } from "../../lib/flags";
|
|
import { useApi } from "../../lib/use-api";
|
|
|
|
interface HolderRow {
|
|
name: string;
|
|
shares: number | null;
|
|
pct: number | null;
|
|
change: number | null;
|
|
value: number | null;
|
|
}
|
|
|
|
interface OwnershipResponse {
|
|
ticker: string;
|
|
available: boolean;
|
|
source: string | null;
|
|
institutional_pct: number | null;
|
|
insider_pct: number | null;
|
|
float_pct: number | null;
|
|
institutions: HolderRow[];
|
|
insiders: HolderRow[];
|
|
}
|
|
|
|
function formatPct(value: number | null): string {
|
|
return value == null ? "—" : `${value.toFixed(1)}%`;
|
|
}
|
|
|
|
function formatShares(value: number | null): string {
|
|
if (value == null) return "—";
|
|
const abs = Math.abs(value);
|
|
if (abs >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
|
if (abs >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
if (abs >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
return value.toFixed(0);
|
|
}
|
|
|
|
function formatChange(value: number | null): string {
|
|
if (value == null) return "—";
|
|
const sign = value > 0 ? "+" : "";
|
|
return `${sign}${formatShares(value)}`;
|
|
}
|
|
|
|
function formatValue(value: number | null): string {
|
|
if (value == null) return "—";
|
|
const abs = Math.abs(value);
|
|
if (abs >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
|
|
if (abs >= 1e9) return `$${(value / 1e9).toFixed(1)}B`;
|
|
if (abs >= 1e6) return `$${(value / 1e6).toFixed(1)}M`;
|
|
return `$${value.toFixed(0)}`;
|
|
}
|
|
|
|
function HolderTable({ title, rows }: { title: string; rows: HolderRow[] }) {
|
|
return (
|
|
<div className="overflow-hidden rounded-md border border-border">
|
|
<div className="border-b border-border bg-surface-sunken px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-brand-navy">
|
|
{title}
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[520px] text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-[11px] uppercase tracking-[0.1em] text-text-muted">
|
|
<th className="px-4 py-2 text-left font-semibold">Holder</th>
|
|
<th className="px-3 py-2 text-right font-semibold">Shares</th>
|
|
<th className="px-3 py-2 text-right font-semibold">%</th>
|
|
<th className="px-3 py-2 text-right font-semibold">Change</th>
|
|
<th className="px-4 py-2 text-right font-semibold">Value</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length > 0 ? rows.map((row) => (
|
|
<tr key={`${title}-${row.name}`} className="border-b border-border/60 last:border-0 hover:bg-surface-sunken">
|
|
<td className="max-w-[240px] truncate px-4 py-2.5 font-semibold text-brand-navy">{row.name}</td>
|
|
<td className="px-3 py-2.5 text-right font-mono tabular-nums text-text-primary">{formatShares(row.shares)}</td>
|
|
<td className="px-3 py-2.5 text-right font-mono tabular-nums text-text-primary">{formatPct(row.pct)}</td>
|
|
<td className={`px-3 py-2.5 text-right font-mono tabular-nums ${row.change == null ? "text-text-muted" : row.change >= 0 ? "text-fin-positive" : "text-fin-negative"}`}>
|
|
{formatChange(row.change)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right font-mono tabular-nums text-text-secondary">{formatValue(row.value)}</td>
|
|
</tr>
|
|
)) : (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-6 text-center text-text-muted">No holder rows available.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OwnershipBar({ data }: { data: OwnershipResponse }) {
|
|
const institutional = data.institutional_pct ?? 0;
|
|
const insider = data.insider_pct ?? 0;
|
|
const float = data.float_pct ?? Math.max(0, 100 - institutional - insider);
|
|
const total = institutional + insider + float || 100;
|
|
const instWidth = (institutional / total) * 100;
|
|
const insiderWidth = (insider / total) * 100;
|
|
const floatWidth = Math.max(0, 100 - instWidth - insiderWidth);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-2 flex justify-between text-[11px] font-mono uppercase tracking-[0.08em] text-text-muted">
|
|
<span>Institutional</span>
|
|
<span>Insider</span>
|
|
<span>Float / Retail</span>
|
|
</div>
|
|
<div className="flex h-4 overflow-hidden rounded-full border border-border bg-surface-sunken" aria-label="Ownership breakdown">
|
|
<div className="bg-brand-navy" style={{ width: `${instWidth}%` }} />
|
|
<div className="bg-brand-gold" style={{ width: `${insiderWidth}%` }} />
|
|
<div className="bg-brand-blue/35" style={{ width: `${floatWidth}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Ownership({ ticker }: { ticker: string }) {
|
|
const url = flags.ownership ? `/api/market/ownership/${encodeURIComponent(ticker)}` : null;
|
|
const { data, loading, error } = useApi<OwnershipResponse>(url, { cacheTtlMs: 300_000 });
|
|
|
|
if (!flags.ownership) return null;
|
|
|
|
return (
|
|
<section className="atlas-card mt-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
|
|
<div>
|
|
<h3 className="font-serif text-lg font-bold text-brand-navy">Ownership</h3>
|
|
<p className="mt-1 text-xs text-text-muted">
|
|
Institutional and insider holder snapshot{data?.source ? ` via ${data.source}` : ""}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-5 p-5">
|
|
<ErrorBanner variant="error" message={error} />
|
|
{loading ? <LoadingPulse height="h-32" label="Loading ownership..." /> : (
|
|
<>
|
|
{!data?.available && (
|
|
<ErrorBanner variant="info" message="Ownership data is not available for this ticker yet." />
|
|
)}
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
|
<StatCard label="Institutional" value={formatPct(data?.institutional_pct ?? null)} />
|
|
<StatCard label="Insider" value={formatPct(data?.insider_pct ?? null)} tone="accent" />
|
|
<StatCard label="Float / Retail" value={formatPct(data?.float_pct ?? null)} />
|
|
</div>
|
|
{data && <OwnershipBar data={data} />}
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
<HolderTable title="Top Institutional Holders" rows={data?.institutions ?? []} />
|
|
<HolderTable title="Insider Activity / Holders" rows={data?.insiders ?? []} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|