diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 01:35:04 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 01:35:04 -0700 |
| commit | 49907ef9991d2f13d53bb02fa648c8a06fbfe80a (patch) | |
| tree | feea76eeeb1a8e7ff3bda8016d5a0b694d00eecc /frontend | |
| parent | fdaef36d7ede89e6711ae65948b7c85d23c8f716 (diff) | |
feat: add ValuationCard component and psm-val-* CSS
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/prism-shell.css | 201 | ||||
| -rw-r--r-- | frontend/components/prism/ValuationCard.tsx | 190 |
2 files changed, 391 insertions, 0 deletions
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 ( + <div className={`psm-val-chip${accent ? " accent" : ""}`}> + <span className="psm-val-chip-label">{label}</span> + <span className="psm-val-chip-price">{price != null ? fmtCurrency(price) : "—"}</span> + {pct != null && ( + <span className={`psm-val-chip-pct ${deltaClass(pct)}`}>{fmtPct(pct, 1, true)}</span> + )} + </div> + ); +} + +function MultipleRow({ + label, + multiple, + price, + current, +}: { + label: string; + multiple?: number | null; + price?: number | null; + current?: number | null; +}) { + const pct = pctVsCurrent(price, current); + return ( + <div className="psm-val-mult-row"> + <span className="psm-val-mult-label">{label}</span> + <span className="psm-val-mult-x">{multiple != null ? `${multiple.toFixed(1)}×` : "—"}</span> + <span className="psm-val-mult-price">{price != null ? fmtCurrency(price) : "—"}</span> + <span className={`psm-val-mult-pct ${pct != null ? deltaClass(pct) : "neutral"}`}> + {pct != null ? fmtPct(pct, 1, true) : "—"} + </span> + </div> + ); +} + +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 ( + <section className="psm-card psm-val-card"> + {/* Summary strip */} + <div className="psm-val-strip"> + <div className="psm-val-chip accent"> + <span className="psm-val-chip-label">Market Price</span> + <span className="psm-val-chip-price"> + {current_price != null ? fmtCurrency(current_price) : "—"} + </span> + </div> + <SummaryChip label="DCF" price={dcfPrice} current={current_price} /> + <SummaryChip + label="EV / EBITDA" + price={ev_ebitda.available ? ev_ebitda.implied_price_per_share : null} + current={current_price} + /> + <SummaryChip + label="EV / Revenue" + price={ev_revenue.available ? ev_revenue.implied_price_per_share : null} + current={current_price} + /> + <SummaryChip + label="P / Book" + price={price_to_book.available ? price_to_book.implied_price_per_share : null} + current={current_price} + /> + </div> + + {/* DCF detail */} + <div className="psm-val-section"> + <div className="psm-val-section-head"> + <span className="psm-eyebrow">Discounted Cash Flow</span> + <span className="psm-val-wacc-note"> + WACC {(dcf.wacc * 100).toFixed(1)}% · Terminal {(dcf.terminal_growth * 100).toFixed(1)}% + </span> + </div> + + {!dcf.available && ( + <p className="psm-muted-copy">Insufficient free cash flow history for DCF.</p> + )} + {dcf.available && dcf.error && ( + <p className="psm-val-dcf-error">{dcf.error}</p> + )} + {dcf.available && !dcf.error && ( + <div className="psm-val-dcf-body"> + <div className="psm-val-kv-list"> + <div className="psm-val-kv-row"> + <span className="psm-val-kv-label">Base FCF (TTM)</span> + <span className="psm-val-kv-val">{dcf.base_fcf != null ? fmtLarge(dcf.base_fcf) : "—"}</span> + </div> + <div className="psm-val-kv-row"> + <span className="psm-val-kv-label">Historical growth</span> + <span className="psm-val-kv-val"> + {dcf.growth_rate_used != null ? fmtPct(dcf.growth_rate_used) : "—"} + </span> + </div> + <div className="psm-val-kv-row is-divider"> + <span className="psm-val-kv-label">Enterprise Value</span> + <span className="psm-val-kv-val"> + {dcf.enterprise_value != null ? fmtLarge(dcf.enterprise_value) : "—"} + </span> + </div> + <div className="psm-val-kv-row"> + <span className="psm-val-kv-label">Net Debt</span> + <span className="psm-val-kv-val"> + {dcf.net_debt != null ? fmtLarge(dcf.net_debt) : "—"} + </span> + </div> + <div className="psm-val-kv-row is-total"> + <span className="psm-val-kv-label">Equity Value</span> + <span className="psm-val-kv-val"> + {dcf.equity_value != null ? fmtLarge(dcf.equity_value) : "—"} + </span> + </div> + </div> + <div className="psm-val-intrinsic"> + <span className="psm-val-intrinsic-label">Intrinsic Value</span> + <span className="psm-val-intrinsic-price"> + {dcf.intrinsic_value_per_share != null ? fmtCurrency(dcf.intrinsic_value_per_share) : "—"} + </span> + {data.shares_outstanding != null && ( + <span className="psm-val-intrinsic-shares"> + {fmtLarge(data.shares_outstanding)} shares + </span> + )} + </div> + </div> + )} + </div> + + {/* Multiples */} + {hasMultiples && ( + <div className="psm-val-section"> + <div className="psm-val-section-head"> + <span className="psm-eyebrow">Multiples — at current market multiple</span> + </div> + <div className="psm-val-mult-list"> + {ev_ebitda.available && ( + <MultipleRow + label="EV / EBITDA" + multiple={ev_ebitda.multiple_used} + price={ev_ebitda.implied_price_per_share} + current={current_price} + /> + )} + {ev_revenue.available && ( + <MultipleRow + label="EV / Revenue" + multiple={ev_revenue.multiple_used} + price={ev_revenue.implied_price_per_share} + current={current_price} + /> + )} + {price_to_book.available && ( + <MultipleRow + label="P / Book" + multiple={price_to_book.multiple_used} + price={price_to_book.implied_price_per_share} + current={current_price} + /> + )} + </div> + </div> + )} + </section> + ); +} |
