From 49907ef9991d2f13d53bb02fa648c8a06fbfe80a Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 18 May 2026 01:35:04 -0700 Subject: feat: add ValuationCard component and psm-val-* CSS --- frontend/app/prism-shell.css | 201 ++++++++++++++++++++++++++++ frontend/components/prism/ValuationCard.tsx | 190 ++++++++++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 frontend/components/prism/ValuationCard.tsx (limited to 'frontend') diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index cd0023a..9a37bdd 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1289,3 +1289,204 @@ .psm-fin-empty { padding: var(--sp-4); } + +/* ── Valuation tab ──────────────────────────────── */ + +.psm-val-card { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-val-strip { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: var(--sp-3); +} + +.psm-val-chip { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-3) var(--sp-4); + background: var(--ink-2); + border-radius: var(--r-2); + border: 1px solid var(--line-1); +} + +.psm-val-chip.accent { + border-color: var(--brass); +} + +.psm-val-chip-label { + font-family: var(--font-sans); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; + color: var(--fg-3); +} + +.psm-val-chip-price { + font-family: var(--font-mono); + font-size: var(--fs-18); + color: var(--fg-1); + font-variant-numeric: tabular-nums; +} + +.psm-val-chip-pct { + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; +} + +.psm-val-section { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding-top: var(--sp-4); + border-top: 1px solid var(--line-1); +} + +.psm-val-section-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.psm-val-wacc-note { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--fg-4); + font-variant-numeric: tabular-nums; +} + +.psm-val-dcf-error { + font-family: var(--font-sans); + font-size: var(--fs-13); + color: var(--warning); + padding: var(--sp-3) var(--sp-4); + background: var(--ink-2); + border-radius: var(--r-2); + border-left: 2px solid var(--warning); +} + +.psm-val-dcf-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-5); +} + +.psm-val-kv-list { + display: flex; + flex-direction: column; +} + +.psm-val-kv-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--ink-2); +} + +.psm-val-kv-row.is-divider { + margin-top: var(--sp-2); + border-top: 1px solid var(--line-1); +} + +.psm-val-kv-row.is-total .psm-val-kv-val { + color: var(--fg-1); + font-weight: 600; +} + +.psm-val-kv-label { + font-family: var(--font-sans); + font-size: var(--fs-13); + color: var(--fg-3); +} + +.psm-val-kv-val { + font-family: var(--font-mono); + font-size: var(--fs-13); + color: var(--fg-2); + font-variant-numeric: tabular-nums; +} + +.psm-val-intrinsic { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-4); + background: var(--ink-2); + border-radius: var(--r-2); + border: 1px solid var(--line-1); + align-self: start; +} + +.psm-val-intrinsic-label { + font-family: var(--font-sans); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; + color: var(--fg-3); +} + +.psm-val-intrinsic-price { + font-family: var(--font-mono); + font-size: var(--fs-38); + color: var(--brass); + font-variant-numeric: tabular-nums; + line-height: 1.1; +} + +.psm-val-intrinsic-shares { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--fg-4); + font-variant-numeric: tabular-nums; +} + +.psm-val-mult-list { + display: flex; + flex-direction: column; +} + +.psm-val-mult-row { + display: grid; + grid-template-columns: 1fr 56px 80px 64px; + gap: var(--sp-4); + align-items: center; + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--ink-2); +} + +.psm-val-mult-label { + font-family: var(--font-sans); + font-size: var(--fs-13); + color: var(--fg-2); +} + +.psm-val-mult-x { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--fg-3); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.psm-val-mult-price { + font-family: var(--font-mono); + font-size: var(--fs-13); + color: var(--fg-1); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.psm-val-mult-pct { + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; + text-align: right; +} diff --git a/frontend/components/prism/ValuationCard.tsx b/frontend/components/prism/ValuationCard.tsx new file mode 100644 index 0000000..7329ff5 --- /dev/null +++ b/frontend/components/prism/ValuationCard.tsx @@ -0,0 +1,190 @@ +"use client"; +import type { ValuationResponse } from "@/types/api"; +import { deltaClass, fmtCurrency, fmtLarge, fmtPct } from "@/lib/format"; + +type Props = { data: ValuationResponse }; + +function pctVsCurrent(implied?: number | null, current?: number | null): number | null { + if (implied == null || current == null || current === 0) return null; + return (implied - current) / current; +} + +function SummaryChip({ + label, + price, + current, + accent = false, +}: { + label: string; + price?: number | null; + current?: number | null; + accent?: boolean; +}) { + const pct = pctVsCurrent(price, current); + return ( +
+ {label} + {price != null ? fmtCurrency(price) : "—"} + {pct != null && ( + {fmtPct(pct, 1, true)} + )} +
+ ); +} + +function MultipleRow({ + label, + multiple, + price, + current, +}: { + label: string; + multiple?: number | null; + price?: number | null; + current?: number | null; +}) { + const pct = pctVsCurrent(price, current); + return ( +
+ {label} + {multiple != null ? `${multiple.toFixed(1)}×` : "—"} + {price != null ? fmtCurrency(price) : "—"} + + {pct != null ? fmtPct(pct, 1, true) : "—"} + +
+ ); +} + +export function ValuationCard({ data }: Props) { + const { dcf, ev_ebitda, ev_revenue, price_to_book, current_price } = data; + const dcfPrice = dcf.available && !dcf.error ? dcf.intrinsic_value_per_share : null; + const hasMultiples = ev_ebitda.available || ev_revenue.available || price_to_book.available; + + return ( +
+ {/* Summary strip */} +
+
+ Market Price + + {current_price != null ? fmtCurrency(current_price) : "—"} + +
+ + + + +
+ + {/* DCF detail */} +
+
+ Discounted Cash Flow + + WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}% + +
+ + {!dcf.available && ( +

Insufficient free cash flow history for DCF.

+ )} + {dcf.available && dcf.error && ( +

{dcf.error}

+ )} + {dcf.available && !dcf.error && ( +
+
+
+ Base FCF (TTM) + {dcf.base_fcf != null ? fmtLarge(dcf.base_fcf) : "—"} +
+
+ Historical growth + + {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"} + +
+
+ Enterprise Value + + {dcf.enterprise_value != null ? fmtLarge(dcf.enterprise_value) : "—"} + +
+
+ Net Debt + + {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"} + +
+
+ Equity Value + + {dcf.equity_value != null ? fmtLarge(dcf.equity_value) : "—"} + +
+
+
+ Intrinsic Value + + {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"} + + {data.shares_outstanding != null && ( + + {fmtLarge(data.shares_outstanding)} shares + + )} +
+
+ )} +
+ + {/* Multiples */} + {hasMultiples && ( +
+
+ Multiples — at current market multiple +
+
+ {ev_ebitda.available && ( + + )} + {ev_revenue.available && ( + + )} + {price_to_book.available && ( + + )} +
+
+ )} +
+ ); +} -- cgit v1.3-2-g0d8e