| 1 | "use client"; |
| 2 | |
| 3 | /* eslint-disable react-hooks/set-state-in-effect -- these resets delimit authenticated async request lifecycles */ |
| 4 | |
| 5 | import { |
| 6 | Bell, |
| 7 | BriefcaseBusiness, |
| 8 | Building2, |
| 9 | ChevronDown, |
| 10 | Command, |
| 11 | Gauge, |
| 12 | LineChart, |
| 13 | Menu, |
| 14 | Moon, |
| 15 | RefreshCw, |
| 16 | Search, |
| 17 | Settings, |
| 18 | ShieldCheck, |
| 19 | SlidersHorizontal, |
| 20 | Sun, |
| 21 | TrendingDown, |
| 22 | TrendingUp, |
| 23 | UploadCloud, |
| 24 | CheckCircle2, |
| 25 | Eye, |
| 26 | EyeOff, |
| 27 | WalletCards, |
| 28 | X |
| 29 | } from "lucide-react"; |
| 30 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 31 | import { createPortal } from "react-dom"; |
| 32 | import { OpportunityRadar } from "./opportunity-radar"; |
| 33 | import { Backtesting } from "./backtesting"; |
| 34 | import { frontendConfig } from "../config"; |
| 35 | import { |
| 36 | type AuthenticatedUser, |
| 37 | type ApiFailure, |
| 38 | type BrokerConnection, |
| 39 | type BrokerProviderInfo, |
| 40 | type Portfolio, |
| 41 | type PortfolioHistory, |
| 42 | type PortfolioHistoryRange, |
| 43 | type PortfolioDashboard, |
| 44 | type MarketUniverseSector, |
| 45 | type SectorPerformance, |
| 46 | type SectorPerformanceStock, |
| 47 | type PortfolioListItem, |
| 48 | type PortfolioImportPreview, |
| 49 | type PortfolioPosition, |
| 50 | type PortfolioResearchCompany, |
| 51 | type PortfolioResearchSummary, |
| 52 | type FinancialResultPeriod, |
| 53 | type FinancialStatementPeriod, |
| 54 | type ProvenancedValue, |
| 55 | type PortfolioSummary, |
| 56 | type ResearchDocument, |
| 57 | type ResearchEvent, |
| 58 | type ResearchSummary, |
| 59 | type CatalystScore, |
| 60 | type ResearchReadiness, |
| 61 | type ResearchReadinessRequirement, |
| 62 | type StockRuleEngineAnalysis, |
| 63 | type ResearchWatchlist, |
| 64 | type WatchlistResearchInstrument, |
| 65 | type WatchlistResearchPresentation, |
| 66 | type ResearchInstrumentMatch, |
| 67 | brokerApi, |
| 68 | authApi, |
| 69 | portfolioApi, |
| 70 | researchApi |
| 71 | } from "../lib/portfolio-api"; |
| 72 | import { |
| 73 | type MarketIntelligenceSelection, |
| 74 | formatSignedPerformancePct, |
| 75 | marketIntelligenceSelection, |
| 76 | performanceDirectionLabel, |
| 77 | performanceRowTone, |
| 78 | regionalWatchlistName |
| 79 | } from "../lib/market-intelligence"; |
| 80 | import { Badge, Button, Card, EmptyState, ErrorState, Field, MetricCard, Skeleton } from "./ui"; |
| 81 | |
| 82 | type View = "dashboard" | "portfolio" | "research" | "backtesting" | "brokers" | "settings"; |
| 83 | type Theme = "system" | "light" | "dark"; |
| 84 | type SortKey = "company" | "ticker" | "marketValue" | "profitLoss" | "allocation"; |
| 85 | type ResearchSectionId = "overview" | "growth" | "orders" | "capex" | "customers" | "guidance" | "news" | "sources"; |
| 86 | type ResearchContext = |
| 87 | | { kind: "PORTFOLIO"; portfolioId: string } |
| 88 | | { kind: "WATCHLIST"; watchlistId: string; name: string; region: SectorPerformance["region"] } |
| 89 | | { kind: "SEARCH"; region: SectorPerformance["region"]; globalInstrumentId: string } |
| 90 | | { kind: "WATCHLIST_PENDING"; name: string; region: SectorPerformance["region"] }; |
| 91 | const portfolioHistoryRanges: PortfolioHistoryRange[] = ["1D", "5D", "1W", "1M", "1Y", "2Y", "3Y", "4Y", "5Y", "MAX"]; |
| 92 | const brokerAuthenticationPollMs = 2000; |
| 93 | const brokerAuthenticationTimeoutMs = 5 * 60 * 1000; |
| 94 | |
| 95 | const navItems: Array<{ id: View; label: string; icon: typeof Gauge }> = [ |
| 96 | { id: "dashboard", label: "Dashboard", icon: Gauge }, |
| 97 | { id: "portfolio", label: "Portfolio", icon: BriefcaseBusiness }, |
| 98 | { id: "research", label: "Research", icon: Search }, |
| 99 | { id: "backtesting", label: "Backtesting", icon: LineChart }, |
| 100 | { id: "brokers", label: "Brokers", icon: WalletCards }, |
| 101 | { id: "settings", label: "Settings", icon: Settings } |
| 102 | ]; |
| 103 | |
| 104 | function formatMoney(amount?: number, currency?: string) { |
| 105 | if (amount === undefined || !currency) { |
| 106 | return "--"; |
| 107 | } |
| 108 | |
| 109 | return new Intl.NumberFormat("en", { |
| 110 | style: "currency", |
| 111 | currency, |
| 112 | maximumFractionDigits: 2 |
| 113 | }).format(amount); |
| 114 | } |
| 115 | |
| 116 | function formatBackendMoney(money?: { amount: number; currency: string } | null) { |
| 117 | return money ? formatMoney(money.amount, money.currency) : "N/A"; |
| 118 | } |
| 119 | |
| 120 | function formatPercent(value?: number | null) { |
| 121 | if (value == null || !Number.isFinite(value)) { |
| 122 | return "N/A"; |
| 123 | } |
| 124 | |
| 125 | return `${value.toFixed(2)}%`; |
| 126 | } |
| 127 | |
| 128 | function formatChangePercent(start?: number, end?: number) { |
| 129 | if (!start || end === undefined) { |
| 130 | return "--"; |
| 131 | } |
| 132 | return formatPercent(((end - start) / start) * 100); |
| 133 | } |
| 134 | |
| 135 | function getAllocationValue(summary: PortfolioSummary | null, position: PortfolioPosition) { |
| 136 | if (!summary?.totalMarketValue || !position.marketValue || summary.totalMarketValue.amount === 0) { |
| 137 | return null; |
| 138 | } |
| 139 | |
| 140 | return (position.marketValue.amount / summary.totalMarketValue.amount) * 100; |
| 141 | } |
| 142 | |
| 143 | function compareNullableDescending(left?: number | null, right?: number | null) { |
| 144 | if (left == null && right == null) return 0; |
| 145 | if (left == null) return 1; |
| 146 | if (right == null) return -1; |
| 147 | return right - left; |
| 148 | } |
| 149 | |
| 150 | function valueTone(value?: number | null): "positive" | "negative" | undefined { |
| 151 | return value == null ? undefined : value >= 0 ? "positive" : "negative"; |
| 152 | } |
| 153 | |
| 154 | function getApiFailure(error: unknown): ApiFailure { |
| 155 | if (typeof error === "object" && error !== null && "message" in error) { |
| 156 | return error as ApiFailure; |
| 157 | } |
| 158 | |
| 159 | return { message: "The portfolio API is not reachable. Confirm the gateway or portfolio service is running." }; |
| 160 | } |
| 161 | |
| 162 | function marketEnsureErrorCategory(error: unknown): string { |
| 163 | if (typeof error === "object" && error !== null && "status" in error) { |
| 164 | const status = (error as { status?: unknown }).status; |
| 165 | if (typeof status === "number") return `HTTP_${status}`; |
| 166 | } |
| 167 | return error instanceof TypeError ? "NETWORK_OR_CLIENT" : "UNKNOWN"; |
| 168 | } |
| 169 | |
| 170 | function hasRealBrokerPositions(positions: PortfolioPosition[]) { |
| 171 | return positions.some((position) => position.dataFreshness === "REAL_BROKER"); |
| 172 | } |
| 173 | |
| 174 | function selectedPortfolioStorageKey(userId: string) { |
| 175 | return `aip.selectedPortfolioId.${userId}`; |
| 176 | } |
| 177 | |
| 178 | function rememberSelectedPortfolioId(userId: string | undefined, portfolioId: string) { |
| 179 | if (!userId || typeof window === "undefined") { |
| 180 | return; |
| 181 | } |
| 182 | if (portfolioId) { |
| 183 | window.localStorage.setItem(selectedPortfolioStorageKey(userId), portfolioId); |
| 184 | } else { |
| 185 | window.localStorage.removeItem(selectedPortfolioStorageKey(userId)); |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | function storedSelectedPortfolioId(userId: string | undefined) { |
| 190 | if (!userId || typeof window === "undefined") { |
| 191 | return ""; |
| 192 | } |
| 193 | return window.localStorage.getItem(selectedPortfolioStorageKey(userId)) ?? ""; |
| 194 | } |
| 195 | |
| 196 | function portfolioSourceLabels(positions: PortfolioPosition[]) { |
| 197 | if (positions.some((position) => position.sourceType === "MANUAL_CSV_IMPORT")) { |
| 198 | return { |
| 199 | badge: "Imported positions", syncMeta: "CSV position snapshot", totalProfitLoss: "Unrealized P/L", |
| 200 | returnLabel: "Unrealized return", marketValue: "Latest market value", costBasis: "Acquisition cost", |
| 201 | unrealizedProfitLoss: "Unrealized P/L", lastUpdated: "from latest accepted public quote", |
| 202 | source: "CSV positions with public market data", syncButton: "Refresh prices", |
| 203 | emptyMessage: "Import a broker statement to populate this view.", sortMarketValue: "Latest market value", |
| 204 | sortProfitLoss: "Unrealized P/L" |
| 205 | }; |
| 206 | } |
| 207 | if (hasRealBrokerPositions(positions)) { |
| 208 | return { |
| 209 | badge: "Real broker data", |
| 210 | syncMeta: "IBKR broker sync", |
| 211 | totalProfitLoss: "Total P/L", |
| 212 | returnLabel: "Return", |
| 213 | marketValue: "Market value", |
| 214 | costBasis: "Cost basis", |
| 215 | unrealizedProfitLoss: "Unrealized P/L", |
| 216 | lastUpdated: "from latest IBKR sync", |
| 217 | source: "Interactive Brokers", |
| 218 | syncButton: "Sync IBKR broker", |
| 219 | emptyMessage: "Sync a broker account to populate this view.", |
| 220 | sortMarketValue: "Market value", |
| 221 | sortProfitLoss: "P/L" |
| 222 | }; |
| 223 | } |
| 224 | |
| 225 | return { |
| 226 | badge: "Demo data", |
| 227 | syncMeta: "Demo broker sync", |
| 228 | totalProfitLoss: "Demo total P/L", |
| 229 | returnLabel: "Demo return", |
| 230 | marketValue: "Demo market value", |
| 231 | costBasis: "Demo cost basis", |
| 232 | unrealizedProfitLoss: "Demo unrealized P/L", |
| 233 | lastUpdated: "from latest mock sync", |
| 234 | source: "Broker demo data", |
| 235 | syncButton: "Sync mock broker", |
| 236 | emptyMessage: "Sync a mock broker account to populate this view.", |
| 237 | sortMarketValue: "Demo market value", |
| 238 | sortProfitLoss: "Demo P/L" |
| 239 | }; |
| 240 | } |
| 241 | |
| 242 | export function InvestmentWorkspace() { |
| 243 | const [accessToken, setAccessToken] = useState<string | null>(() => |
| 244 | typeof window === "undefined" ? null : window.localStorage.getItem("aip.accessToken") |
| 245 | ); |
| 246 | const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(() => { |
| 247 | if (typeof window === "undefined") { |
| 248 | return null; |
| 249 | } |
| 250 | const storedUser = window.localStorage.getItem("aip.user"); |
| 251 | return storedUser ? (JSON.parse(storedUser) as AuthenticatedUser) : null; |
| 252 | }); |
| 253 | const [authLoading, setAuthLoading] = useState(false); |
| 254 | const marketEnsureAuthReady = authenticatedUser !== null && Boolean(accessToken); |
| 255 | const [view, setView] = useState<View>("dashboard"); |
| 256 | const [theme, setTheme] = useState<Theme>("system"); |
| 257 | const [sidebarOpen, setSidebarOpen] = useState(false); |
| 258 | const [portfolios, setPortfolios] = useState<PortfolioListItem[]>([]); |
| 259 | const [portfolioDashboard, setPortfolioDashboard] = useState<PortfolioDashboard | null>(null); |
| 260 | const [sectorPerformance, setSectorPerformance] = useState<SectorPerformance | null>(null); |
| 261 | const [sectorPerformanceRegion, setSectorPerformanceRegion] = useState<SectorPerformance["region"]>("EUROPE"); |
| 262 | const [sectorPerformanceSector, setSectorPerformanceSector] = useState(""); |
| 263 | const [sectorOptions, setSectorOptions] = useState<MarketUniverseSector[]>([]); |
| 264 | const [sectorOptionsRegion, setSectorOptionsRegion] = useState<SectorPerformance["region"] | null>(null); |
| 265 | const [sectorOptionsLoading, setSectorOptionsLoading] = useState(false); |
| 266 | const [sectorPerformancePeriod, setSectorPerformancePeriod] = useState<SectorPerformance["period"]>("WEEK"); |
| 267 | const [portfolioScope, setPortfolioScope] = useState<"ALL" | string>("ALL"); |
| 268 | const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | undefined>(); |
| 269 | const [selectedPortfolioId, setSelectedPortfolioId] = useState<string>(""); |
| 270 | const [summary, setSummary] = useState<PortfolioSummary | null>(null); |
| 271 | const [positions, setPositions] = useState<PortfolioPosition[]>([]); |
| 272 | const [portfolioHistory, setPortfolioHistory] = useState<PortfolioHistory | null>(null); |
| 273 | const [portfolioHistoryRange, setPortfolioHistoryRange] = useState<PortfolioHistoryRange>("1M"); |
| 274 | const [portfolioHistoryLoading, setPortfolioHistoryLoading] = useState(false); |
| 275 | const [loading, setLoading] = useState(false); |
| 276 | const [syncing, setSyncing] = useState(false); |
| 277 | const [creating, setCreating] = useState(false); |
| 278 | const [error, setError] = useState<ApiFailure | null>(null); |
| 279 | const [newPortfolioName, setNewPortfolioName] = useState("My Global Portfolio"); |
| 280 | const [newPortfolioCurrency, setNewPortfolioCurrency] = useState("EUR"); |
| 281 | const [searchText, setSearchText] = useState(""); |
| 282 | const [sortKey, setSortKey] = useState<SortKey>("marketValue"); |
| 283 | const [brokerProviders, setBrokerProviders] = useState<BrokerProviderInfo[]>([]); |
| 284 | const [brokerConnections, setBrokerConnections] = useState<BrokerConnection[]>([]); |
| 285 | const [brokerLoading, setBrokerLoading] = useState(false); |
| 286 | const [brokerErrors, setBrokerErrors] = useState<Record<string, string>>({}); |
| 287 | const [authenticatingBroker, setAuthenticatingBroker] = useState<string | null>(null); |
| 288 | const [brokerAuthenticationTimedOut, setBrokerAuthenticationTimedOut] = useState(false); |
| 289 | const brokerAuthPopupRef = useRef<Window | null>(null); |
| 290 | const brokerAuthPollRef = useRef<number | null>(null); |
| 291 | const brokerAuthPollBusyRef = useRef(false); |
| 292 | const pendingBrokerAuthRef = useRef<{ connectionId: string; provider: string } | null>(null); |
| 293 | const brokerAuthStartedAtRef = useRef(0); |
| 294 | const ibkrBootstrapSyncsRef = useRef(new Map<string, Promise<boolean>>()); |
| 295 | const [selectedResearchInstrumentId, setSelectedResearchInstrumentId] = useState(""); |
| 296 | const [selectedMarketIntelligenceStock, setSelectedMarketIntelligenceStock] = useState<SectorPerformanceStock | null>(null); |
| 297 | const [selectedMarketIntelligenceRegion, setSelectedMarketIntelligenceRegion] = useState<SectorPerformance["region"] | null>(null); |
| 298 | const [researchSummary, setResearchSummary] = useState<ResearchSummary | null>(null); |
| 299 | const [researchLoading, setResearchLoading] = useState(false); |
| 300 | const [portfolioResearchSummary, setPortfolioResearchSummary] = useState<PortfolioResearchSummary | null>(null); |
| 301 | const [portfolioResearchLoading, setPortfolioResearchLoading] = useState(false); |
| 302 | const [portfolioResearchError, setPortfolioResearchError] = useState<string | null>(null); |
| 303 | const [researchReadinessDialog, setResearchReadinessDialog] = useState<{ |
| 304 | globalInstrumentId: string; |
| 305 | companyName: string; |
| 306 | } | null>(null); |
| 307 | const [researchReadiness, setResearchReadiness] = useState<ResearchReadiness | null>(null); |
| 308 | const [researchReadinessLoading, setResearchReadinessLoading] = useState(false); |
| 309 | const [researchReadinessError, setResearchReadinessError] = useState<string | null>(null); |
| 310 | const [ensuringResearchRequirements, setEnsuringResearchRequirements] = useState<string[]>([]); |
| 311 | const [stockRuleEngineAnalysis, setStockRuleEngineAnalysis] = useState<StockRuleEngineAnalysis | null>(null); |
| 312 | const [stockRuleEngineLoading, setStockRuleEngineLoading] = useState(false); |
| 313 | const [stockRuleEngineError, setStockRuleEngineError] = useState<string | null>(null); |
| 314 | const [researchEventType, setResearchEventType] = useState(""); |
| 315 | const [researchImpact, setResearchImpact] = useState(""); |
| 316 | const [watchlists, setWatchlists] = useState<ResearchWatchlist[]>([]); |
| 317 | const [savedWatchlistIds, setSavedWatchlistIds] = useState<Record<string, string[]>>({}); |
| 318 | const [watchlistMutation, setWatchlistMutation] = useState<string | null>(null); |
| 319 | const [watchlistActionError, setWatchlistActionError] = useState<string | null>(null); |
| 320 | const [watchlistRevision, setWatchlistRevision] = useState(0); |
| 321 | const watchlistMutationRef = useRef(false); |
| 322 | const [researchContext, setResearchContext] = useState<ResearchContext>({ kind: "PORTFOLIO", portfolioId: "" }); |
| 323 | const [watchlistResearch, setWatchlistResearch] = useState<WatchlistResearchPresentation | null>(null); |
| 324 | const [watchlistResearchLoading, setWatchlistResearchLoading] = useState(false); |
| 325 | const [watchlistResearchError, setWatchlistResearchError] = useState<string | null>(null); |
| 326 | const positionsRef = useRef<PortfolioPosition[]>([]); |
| 327 | const researchContextRef = useRef<ResearchContext>(researchContext); |
| 328 | |
| 329 | useEffect(() => { |
| 330 | positionsRef.current = positions; |
| 331 | }, [positions]); |
| 332 | |
| 333 | useEffect(() => { |
| 334 | researchContextRef.current = researchContext; |
| 335 | }, [researchContext]); |
| 336 | |
| 337 | const clearUserScopedState = useCallback(() => { |
| 338 | rememberSelectedPortfolioId(authenticatedUser?.userId, ""); |
| 339 | setPortfolios([]); |
| 340 | setPortfolioDashboard(null); |
| 341 | setPortfolioScope("ALL"); |
| 342 | setSelectedPortfolio(undefined); |
| 343 | setSelectedPortfolioId(""); |
| 344 | setSummary(null); |
| 345 | setPositions([]); |
| 346 | setPortfolioHistory(null); |
| 347 | setBrokerConnections([]); |
| 348 | setBrokerProviders([]); |
| 349 | setSelectedResearchInstrumentId(""); |
| 350 | setSelectedMarketIntelligenceStock(null); |
| 351 | setSelectedMarketIntelligenceRegion(null); |
| 352 | setWatchlists([]); |
| 353 | setSavedWatchlistIds({}); |
| 354 | setWatchlistActionError(null); |
| 355 | setResearchContext({ kind: "PORTFOLIO", portfolioId: "" }); |
| 356 | setWatchlistResearch(null); |
| 357 | setWatchlistResearchError(null); |
| 358 | setResearchReadinessDialog(null); |
| 359 | setResearchReadiness(null); |
| 360 | setResearchReadinessError(null); |
| 361 | setStockRuleEngineAnalysis(null); |
| 362 | setStockRuleEngineError(null); |
| 363 | setResearchSummary(null); |
| 364 | setPortfolioResearchSummary(null); |
| 365 | setSearchText(""); |
| 366 | }, [authenticatedUser?.userId]); |
| 367 | |
| 368 | useEffect(() => { |
| 369 | document.documentElement.dataset.theme = theme; |
| 370 | }, [theme]); |
| 371 | |
| 372 | useEffect(() => { |
| 373 | const params = new URLSearchParams(window.location.search); |
| 374 | const apiSession = params.get("API_Session") ?? params.get("api_session"); |
| 375 | const requestToken = params.get("request_token"); |
| 376 | const pending = window.localStorage.getItem("aip.pendingBrokerAuthentication"); |
| 377 | if ((!apiSession && !requestToken) || !pending || !accessToken) return; |
| 378 | const value = JSON.parse(pending) as { connectionId: string; provider: string }; |
| 379 | const completion = apiSession && value.provider === "ICICI_DIRECT" |
| 380 | ? brokerApi.attachIciciSession(value.connectionId, apiSession) |
| 381 | : requestToken && value.provider === "HDFC_SECURITIES" |
| 382 | ? brokerApi.attachHdfcRequestToken(value.connectionId, requestToken) |
| 383 | : null; |
| 384 | if (!completion) return; |
| 385 | void completion.then(() => { |
| 386 | window.localStorage.removeItem("aip.pendingBrokerAuthentication"); |
| 387 | if (window.opener) window.close(); |
| 388 | else window.history.replaceState({}, "", window.location.pathname); |
| 389 | }).catch(() => setBrokerErrors({})); |
| 390 | }, [accessToken]); |
| 391 | |
| 392 | useEffect(() => { |
| 393 | function handleUnauthorized() { |
| 394 | setAccessToken(null); |
| 395 | setAuthenticatedUser(null); |
| 396 | clearUserScopedState(); |
| 397 | setError({ message: "Your session expired. Sign in again." }); |
| 398 | } |
| 399 | |
| 400 | window.addEventListener("aip:unauthorized", handleUnauthorized); |
| 401 | return () => window.removeEventListener("aip:unauthorized", handleUnauthorized); |
| 402 | }, [clearUserScopedState]); |
| 403 | |
| 404 | useEffect(() => { |
| 405 | function handleBrokerAuthentication(event: MessageEvent) { |
| 406 | if (event.origin !== window.location.origin || event.data?.type !== "aip:ibkr-authenticated") return; |
| 407 | const pending = pendingBrokerAuthRef.current; |
| 408 | if (!pending || pending.provider !== "IBKR") return; |
| 409 | void finishBrokerAuthentication(pending.connectionId); |
| 410 | } |
| 411 | window.addEventListener("message", handleBrokerAuthentication); |
| 412 | return () => window.removeEventListener("message", handleBrokerAuthentication); |
| 413 | }); |
| 414 | |
| 415 | useEffect(() => () => stopBrokerAuthenticationMonitoring(false), []); |
| 416 | |
| 417 | useEffect(() => { |
| 418 | let cancelled = false; |
| 419 | |
| 420 | async function loadPortfolios() { |
| 421 | setLoading(true); |
| 422 | setError(null); |
| 423 | try { |
| 424 | const dashboard = await portfolioApi.getDashboard(); |
| 425 | const loaded = dashboard.portfolios; |
| 426 | if (cancelled) { |
| 427 | return; |
| 428 | } |
| 429 | setPortfolios(loaded); |
| 430 | setPortfolioDashboard(dashboard); |
| 431 | setSelectedPortfolioId((current) => { |
| 432 | const stored = storedSelectedPortfolioId(authenticatedUser?.userId); |
| 433 | const next = current && loaded.some((portfolio) => portfolio.portfolioId === current) |
| 434 | ? current |
| 435 | : stored && loaded.some((portfolio) => portfolio.portfolioId === stored) |
| 436 | ? stored |
| 437 | : loaded[0]?.portfolioId || ""; |
| 438 | rememberSelectedPortfolioId(authenticatedUser?.userId, next); |
| 439 | return next; |
| 440 | }); |
| 441 | if (loaded.length === 0) { |
| 442 | rememberSelectedPortfolioId(authenticatedUser?.userId, ""); |
| 443 | setSelectedPortfolio(undefined); |
| 444 | setSummary(null); |
| 445 | setPositions([]); |
| 446 | } |
| 447 | } catch (err) { |
| 448 | if (!cancelled) { |
| 449 | setError(getApiFailure(err)); |
| 450 | } |
| 451 | } finally { |
| 452 | if (!cancelled) { |
| 453 | setLoading(false); |
| 454 | } |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | if (accessToken && authenticatedUser) { |
| 459 | void loadPortfolios(); |
| 460 | } |
| 461 | return () => { |
| 462 | cancelled = true; |
| 463 | }; |
| 464 | }, [accessToken, authenticatedUser?.userId]); |
| 465 | |
| 466 | useEffect(() => { |
| 467 | console.info("[AIP_MARKET_ENSURE]", { event: "EFFECT", authReady: marketEnsureAuthReady }); |
| 468 | if (!marketEnsureAuthReady) return; |
| 469 | |
| 470 | console.info("[AIP_MARKET_ENSURE]", { event: "DISPATCH" }); |
| 471 | void portfolioApi.ensureMarketData("INDIA") |
| 472 | .then(() => console.info("[AIP_MARKET_ENSURE]", { event: "RESOLVED" })) |
| 473 | .catch((error: unknown) => console.info( |
| 474 | "[AIP_MARKET_ENSURE]", |
| 475 | { event: "REJECTED", category: marketEnsureErrorCategory(error) } |
| 476 | )); |
| 477 | }, [marketEnsureAuthReady]); |
| 478 | |
| 479 | useEffect(() => { |
| 480 | if (!marketEnsureAuthReady) return; |
| 481 | let cancelled = false; |
| 482 | void researchApi.listWatchlists() |
| 483 | .then(async (values) => { |
| 484 | const memberships = await Promise.all(values.map(async (list) => { |
| 485 | const detail = await researchApi.getWatchlistResearch(list.watchlistId); |
| 486 | return [list.watchlistId, detail.instruments.map((item) => item.globalInstrumentId)] as const; |
| 487 | })); |
| 488 | if (!cancelled) { |
| 489 | setWatchlists(values); |
| 490 | setSavedWatchlistIds(Object.fromEntries(memberships)); |
| 491 | } |
| 492 | }) |
| 493 | .catch(() => { if (!cancelled) setWatchlistActionError("Saved watchlists could not be loaded. Please retry."); }); |
| 494 | return () => { cancelled = true; }; |
| 495 | }, [marketEnsureAuthReady]); |
| 496 | |
| 497 | useEffect(() => { |
| 498 | setResearchContext((current) => current.kind === "PORTFOLIO" |
| 499 | ? { kind: "PORTFOLIO", portfolioId: selectedPortfolioId } |
| 500 | : current); |
| 501 | }, [selectedPortfolioId]); |
| 502 | |
| 503 | useEffect(() => { |
| 504 | if (!accessToken || !authenticatedUser) return; |
| 505 | let cancelled = false; |
| 506 | setSectorOptions([]); |
| 507 | setSectorOptionsRegion(null); |
| 508 | setSectorPerformanceSector(""); |
| 509 | setSectorPerformance(null); |
| 510 | setSectorOptionsLoading(true); |
| 511 | void portfolioApi.getMarketUniverseSectors(sectorPerformanceRegion) |
| 512 | .then((value) => { |
| 513 | if (cancelled) return; |
| 514 | setSectorOptions(value.sectors); |
| 515 | setSectorOptionsRegion(value.region); |
| 516 | setSectorPerformanceSector(value.sectors[0]?.name ?? ""); |
| 517 | }) |
| 518 | .catch(() => { |
| 519 | if (!cancelled) { |
| 520 | setSectorOptions([]); |
| 521 | setSectorOptionsRegion(sectorPerformanceRegion); |
| 522 | } |
| 523 | }) |
| 524 | .finally(() => { if (!cancelled) setSectorOptionsLoading(false); }); |
| 525 | return () => { cancelled = true; }; |
| 526 | }, [accessToken, authenticatedUser, sectorPerformanceRegion]); |
| 527 | |
| 528 | useEffect(() => { |
| 529 | const validSector = sectorOptionsRegion === sectorPerformanceRegion |
| 530 | && sectorOptions.some((option) => option.name === sectorPerformanceSector); |
| 531 | if (!accessToken || !authenticatedUser || !validSector) { |
| 532 | setSectorPerformance(null); |
| 533 | return; |
| 534 | } |
| 535 | let cancelled = false; |
| 536 | setSectorPerformance(null); |
| 537 | void portfolioApi.getSectorPerformance(sectorPerformanceRegion, sectorPerformanceSector, sectorPerformancePeriod) |
| 538 | .then((value) => { if (!cancelled) setSectorPerformance(value); }) |
| 539 | .catch(() => { if (!cancelled) setSectorPerformance(null); }); |
| 540 | return () => { cancelled = true; }; |
| 541 | }, [accessToken, authenticatedUser, sectorOptions, sectorOptionsRegion, sectorPerformanceRegion, sectorPerformanceSector, sectorPerformancePeriod]); |
| 542 | |
| 543 | useEffect(() => { |
| 544 | let cancelled = false; |
| 545 | |
| 546 | async function loadResearchSummary() { |
| 547 | if (!selectedResearchInstrumentId) { |
| 548 | setResearchSummary(null); |
| 549 | return; |
| 550 | } |
| 551 | const selectedCompany = researchContext.kind === "WATCHLIST" |
| 552 | ? watchlistResearch?.instruments.find((value) => value.globalInstrumentId === selectedResearchInstrumentId)?.company |
| 553 | : portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedResearchInstrumentId); |
| 554 | if (selectedCompany && !canRefreshResearch(selectedCompany)) { |
| 555 | setResearchSummary(null); |
| 556 | setResearchLoading(false); |
| 557 | return; |
| 558 | } |
| 559 | setResearchLoading(true); |
| 560 | try { |
| 561 | const loaded = await researchApi.getSummary(selectedResearchInstrumentId); |
| 562 | if (!cancelled) { |
| 563 | setResearchSummary(loaded); |
| 564 | } |
| 565 | } catch { |
| 566 | if (!cancelled) { |
| 567 | setResearchSummary(null); |
| 568 | } |
| 569 | } finally { |
| 570 | if (!cancelled) { |
| 571 | setResearchLoading(false); |
| 572 | } |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | void loadResearchSummary(); |
| 577 | return () => { |
| 578 | cancelled = true; |
| 579 | }; |
| 580 | }, [portfolioResearchSummary, researchContext, selectedResearchInstrumentId, watchlistResearch]); |
| 581 | |
| 582 | useEffect(() => { |
| 583 | if (!marketEnsureAuthReady || researchContext.kind !== "WATCHLIST") { |
| 584 | setWatchlistResearch(null); |
| 585 | setWatchlistResearchError(null); |
| 586 | return; |
| 587 | } |
| 588 | let cancelled = false; |
| 589 | setWatchlistResearchLoading(true); |
| 590 | setWatchlistResearchError(null); |
| 591 | void researchApi.getWatchlistResearch(researchContext.watchlistId) |
| 592 | .then((value) => { |
| 593 | if (cancelled) return; |
| 594 | setWatchlistResearch(value); |
| 595 | setSelectedResearchInstrumentId((current) => value.instruments.some( |
| 596 | (item) => item.globalInstrumentId === current |
| 597 | ) ? current : value.instruments[0]?.globalInstrumentId ?? ""); |
| 598 | }) |
| 599 | .catch(() => { |
| 600 | if (!cancelled) { |
| 601 | setWatchlistResearch(null); |
| 602 | setWatchlistResearchError("Watchlist research is temporarily unavailable."); |
| 603 | } |
| 604 | }) |
| 605 | .finally(() => { if (!cancelled) setWatchlistResearchLoading(false); }); |
| 606 | return () => { cancelled = true; }; |
| 607 | }, [marketEnsureAuthReady, researchContext, watchlistRevision]); |
| 608 | |
| 609 | useEffect(() => { |
| 610 | let cancelled = false; |
| 611 | |
| 612 | async function loadPortfolioResearchSummary() { |
| 613 | if (!selectedPortfolioId) { |
| 614 | setPortfolioResearchSummary(null); |
| 615 | setSelectedResearchInstrumentId(""); |
| 616 | return; |
| 617 | } |
| 618 | setPortfolioResearchLoading(true); |
| 619 | setPortfolioResearchError(null); |
| 620 | try { |
| 621 | const loaded = await researchApi.getPortfolioSummary(selectedPortfolioId); |
| 622 | if (cancelled) { |
| 623 | return; |
| 624 | } |
| 625 | setPortfolioResearchSummary(loaded); |
| 626 | if (researchContextRef.current.kind === "PORTFOLIO") { |
| 627 | setSelectedResearchInstrumentId((current) => current |
| 628 | && loaded.companies.some((company) => company.instrumentId === current) |
| 629 | ? current : loaded.companies.find((company) => canRefreshResearch(company))?.instrumentId ?? ""); |
| 630 | } |
| 631 | } catch { |
| 632 | if (!cancelled) { |
| 633 | setPortfolioResearchSummary(null); |
| 634 | setPortfolioResearchError("Portfolio research summary unavailable. Existing company research remains available."); |
| 635 | if (researchContextRef.current.kind === "PORTFOLIO") { |
| 636 | setSelectedResearchInstrumentId((current) => current || positionsRef.current.find( |
| 637 | (position) => position.instrument.globalInstrumentId |
| 638 | )?.instrument.globalInstrumentId || ""); |
| 639 | } |
| 640 | } |
| 641 | } finally { |
| 642 | if (!cancelled) { |
| 643 | setPortfolioResearchLoading(false); |
| 644 | } |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | void loadPortfolioResearchSummary(); |
| 649 | return () => { |
| 650 | cancelled = true; |
| 651 | }; |
| 652 | }, [selectedPortfolioId]); |
| 653 | |
| 654 | useEffect(() => { |
| 655 | let cancelled = false; |
| 656 | |
| 657 | async function loadPortfolioDetail() { |
| 658 | setLoading(true); |
| 659 | setError(null); |
| 660 | try { |
| 661 | const [loadedPortfolio, loadedSummary, loadedPositions] = await Promise.all([ |
| 662 | portfolioApi.getPortfolio(selectedPortfolioId), |
| 663 | portfolioApi.getSummary(selectedPortfolioId), |
| 664 | portfolioApi.getPositions(selectedPortfolioId) |
| 665 | ]); |
| 666 | if (!cancelled) { |
| 667 | setSelectedPortfolio(loadedPortfolio); |
| 668 | setSummary(loadedSummary); |
| 669 | setPositions(loadedPositions); |
| 670 | } |
| 671 | } catch (err) { |
| 672 | if (!cancelled) { |
| 673 | setError(getApiFailure(err)); |
| 674 | setSummary(null); |
| 675 | setPositions([]); |
| 676 | } |
| 677 | } finally { |
| 678 | if (!cancelled) { |
| 679 | setLoading(false); |
| 680 | } |
| 681 | } |
| 682 | } |
| 683 | |
| 684 | if (!selectedPortfolioId) { |
| 685 | return; |
| 686 | } |
| 687 | |
| 688 | void loadPortfolioDetail(); |
| 689 | return () => { |
| 690 | cancelled = true; |
| 691 | }; |
| 692 | }, [selectedPortfolioId]); |
| 693 | |
| 694 | useEffect(() => { |
| 695 | let cancelled = false; |
| 696 | |
| 697 | async function loadPortfolioHistory() { |
| 698 | if (!selectedPortfolioId) { |
| 699 | return; |
| 700 | } |
| 701 | setPortfolioHistoryLoading(true); |
| 702 | try { |
| 703 | const loaded = await portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange); |
| 704 | if (!cancelled) { |
| 705 | setPortfolioHistory(loaded); |
| 706 | } |
| 707 | } catch { |
| 708 | if (!cancelled) { |
| 709 | setPortfolioHistory(null); |
| 710 | } |
| 711 | } finally { |
| 712 | if (!cancelled) { |
| 713 | setPortfolioHistoryLoading(false); |
| 714 | } |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | if (accessToken && authenticatedUser) { |
| 719 | void loadPortfolioHistory(); |
| 720 | } |
| 721 | return () => { |
| 722 | cancelled = true; |
| 723 | }; |
| 724 | }, [accessToken, selectedPortfolioId, portfolioHistoryRange]); |
| 725 | |
| 726 | useEffect(() => { |
| 727 | let cancelled = false; |
| 728 | |
| 729 | async function loadBrokers() { |
| 730 | setBrokerLoading(true); |
| 731 | try { |
| 732 | const [providers, connections] = await Promise.all([brokerApi.listBrokers(), brokerApi.listConnections()]); |
| 733 | if (!cancelled) { |
| 734 | setBrokerProviders(providers); |
| 735 | setBrokerConnections(connections); |
| 736 | } |
| 737 | } catch { |
| 738 | if (!cancelled) { |
| 739 | setBrokerProviders([]); |
| 740 | setBrokerConnections([]); |
| 741 | } |
| 742 | } finally { |
| 743 | if (!cancelled) { |
| 744 | setBrokerLoading(false); |
| 745 | } |
| 746 | } |
| 747 | } |
| 748 | |
| 749 | if (accessToken && authenticatedUser) { |
| 750 | void loadBrokers(); |
| 751 | } |
| 752 | return () => { |
| 753 | cancelled = true; |
| 754 | }; |
| 755 | }, [accessToken]); |
| 756 | |
| 757 | const sortedPositions = useMemo(() => { |
| 758 | const normalizedSearch = searchText.trim().toLowerCase(); |
| 759 | return positions |
| 760 | .filter((position) => { |
| 761 | if (!normalizedSearch) { |
| 762 | return true; |
| 763 | } |
| 764 | return [ |
| 765 | position.displayName, |
| 766 | position.instrument.companyName, |
| 767 | position.instrument.ticker, |
| 768 | position.instrument.isin, |
| 769 | position.instrument.exchange, |
| 770 | position.instrument.country |
| 771 | ] |
| 772 | .filter(Boolean) |
| 773 | .some((value) => String(value).toLowerCase().includes(normalizedSearch)); |
| 774 | }) |
| 775 | .sort((a, b) => { |
| 776 | switch (sortKey) { |
| 777 | case "company": |
| 778 | return a.displayName.localeCompare(b.displayName); |
| 779 | case "ticker": |
| 780 | return a.instrument.ticker.localeCompare(b.instrument.ticker); |
| 781 | case "profitLoss": |
| 782 | return compareNullableDescending(a.unrealizedProfitLoss?.amount, b.unrealizedProfitLoss?.amount); |
| 783 | case "allocation": |
| 784 | return compareNullableDescending(getAllocationValue(summary, a), getAllocationValue(summary, b)); |
| 785 | case "marketValue": |
| 786 | default: |
| 787 | return compareNullableDescending(a.marketValue?.amount, b.marketValue?.amount); |
| 788 | } |
| 789 | }); |
| 790 | }, [positions, searchText, sortKey, summary]); |
| 791 | |
| 792 | async function updateHoldingDisplayName(position: PortfolioPosition, customDisplayName: string | null) { |
| 793 | const updated = await portfolioApi.updateHoldingDisplayName(position.portfolioId, position.positionId, customDisplayName); |
| 794 | setPositions((current) => current.map((item) => item.positionId === updated.positionId ? updated : item)); |
| 795 | setPortfolioDashboard(await portfolioApi.getDashboard()); |
| 796 | } |
| 797 | |
| 798 | function clearMarketIntelligenceContext() { |
| 799 | setSelectedMarketIntelligenceStock(null); |
| 800 | setSelectedMarketIntelligenceRegion(null); |
| 801 | setWatchlistResearch(null); |
| 802 | setWatchlistResearchError(null); |
| 803 | setResearchContext({ kind: "PORTFOLIO", portfolioId: selectedPortfolioId }); |
| 804 | setSelectedResearchInstrumentId( |
| 805 | portfolioResearchSummary?.companies.find((company) => canRefreshResearch(company))?.instrumentId ?? "" |
| 806 | ); |
| 807 | setResearchSummary(null); |
| 808 | } |
| 809 | |
| 810 | async function openResearchReadiness( |
| 811 | globalInstrumentId: string, |
| 812 | companyName: string |
| 813 | ) { |
| 814 | setResearchReadinessDialog({ globalInstrumentId, companyName }); |
| 815 | setResearchReadiness(null); |
| 816 | setResearchReadinessError(null); |
| 817 | setResearchReadinessLoading(true); |
| 818 | try { |
| 819 | setResearchReadiness(await researchApi.getReadiness(globalInstrumentId)); |
| 820 | } catch { |
| 821 | setResearchReadinessError("Research readiness is temporarily unavailable."); |
| 822 | } finally { |
| 823 | setResearchReadinessLoading(false); |
| 824 | } |
| 825 | } |
| 826 | |
| 827 | async function findResearchData(requirements: string[]) { |
| 828 | const dialog = researchReadinessDialog; |
| 829 | if (!dialog || requirements.length === 0) return; |
| 830 | setEnsuringResearchRequirements(requirements); |
| 831 | setResearchReadinessError(null); |
| 832 | try { |
| 833 | setResearchReadiness( |
| 834 | await researchApi.ensureReadiness(dialog.globalInstrumentId, requirements) |
| 835 | ); |
| 836 | setStockRuleEngineAnalysis(null); |
| 837 | } catch { |
| 838 | setResearchReadinessError("Targeted research acquisition could not be completed."); |
| 839 | } finally { |
| 840 | setEnsuringResearchRequirements([]); |
| 841 | } |
| 842 | } |
| 843 | |
| 844 | async function runStockRuleEngineAnalysis(allowPartial: boolean) { |
| 845 | const dialog = researchReadinessDialog; |
| 846 | if (!dialog) return; |
| 847 | setStockRuleEngineLoading(true); |
| 848 | setStockRuleEngineError(null); |
| 849 | try { |
| 850 | setStockRuleEngineAnalysis( |
| 851 | await researchApi.analyze(dialog.globalInstrumentId, allowPartial) |
| 852 | ); |
| 853 | } catch { |
| 854 | setStockRuleEngineError("Deterministic analysis could not be completed."); |
| 855 | } finally { |
| 856 | setStockRuleEngineLoading(false); |
| 857 | } |
| 858 | } |
| 859 | |
| 860 | function openMarketIntelligenceResearch(selection: MarketIntelligenceSelection) { |
| 861 | const { stock, region } = selection; |
| 862 | setSelectedMarketIntelligenceStock(stock); |
| 863 | setSelectedMarketIntelligenceRegion(region); |
| 864 | setSelectedResearchInstrumentId(stock.globalInstrumentId); |
| 865 | setResearchContext({ kind: "SEARCH", region, globalInstrumentId: stock.globalInstrumentId }); |
| 866 | void openResearchReadiness(stock.globalInstrumentId, stock.companyName); |
| 867 | void loadSearchPresentation(stock.globalInstrumentId, stock.companyName, region); |
| 868 | } |
| 869 | |
| 870 | async function openRegionalWatchlist(region: SectorPerformance["region"]) { |
| 871 | setWatchlistActionError(null); |
| 872 | try { |
| 873 | const list = await researchApi.ensureDefaultWatchlist(region); |
| 874 | setWatchlists((current) => [...current.filter((item) => item.watchlistId !== list.watchlistId), list]); |
| 875 | setSelectedResearchInstrumentId(""); |
| 876 | setResearchContext({ kind: "WATCHLIST", watchlistId: list.watchlistId, name: list.name, region: list.region }); |
| 877 | } catch (error) { |
| 878 | setWatchlistActionError(getApiFailure(error).message); |
| 879 | } |
| 880 | } |
| 881 | |
| 882 | async function addRankedStockToWatchlist(selection: MarketIntelligenceSelection) { |
| 883 | if (watchlistMutationRef.current || !selection.stock.globalInstrumentId?.trim()) return; |
| 884 | watchlistMutationRef.current = true; |
| 885 | setWatchlistMutation(selection.stock.globalInstrumentId); |
| 886 | setWatchlistActionError(null); |
| 887 | try { |
| 888 | const list = await researchApi.ensureDefaultWatchlist(selection.region); |
| 889 | await researchApi.addWatchlistInstrument(list.watchlistId, { |
| 890 | globalInstrumentId: selection.stock.globalInstrumentId, |
| 891 | sourcePeriod: selection.period, |
| 892 | sourcePerformancePct: selection.stock.performancePct, |
| 893 | }); |
| 894 | const detail = await researchApi.getWatchlistResearch(list.watchlistId); |
| 895 | setWatchlists((current) => [...current.filter((item) => item.watchlistId !== list.watchlistId), detail.watchlist]); |
| 896 | setSavedWatchlistIds((current) => ({ ...current, [list.watchlistId]: detail.instruments.map((item) => item.globalInstrumentId) })); |
| 897 | setWatchlistRevision((value) => value + 1); |
| 898 | } catch (error) { |
| 899 | setWatchlistActionError(getApiFailure(error).message); |
| 900 | } finally { |
| 901 | watchlistMutationRef.current = false; |
| 902 | setWatchlistMutation(null); |
| 903 | } |
| 904 | } |
| 905 | |
| 906 | async function removeWatchlistStock(globalInstrumentId: string) { |
| 907 | if (researchContext.kind !== "WATCHLIST" || watchlistMutationRef.current) return; |
| 908 | const { watchlistId } = researchContext; |
| 909 | watchlistMutationRef.current = true; |
| 910 | setWatchlistMutation(globalInstrumentId); |
| 911 | setWatchlistActionError(null); |
| 912 | try { |
| 913 | await researchApi.removeWatchlistInstrument(watchlistId, globalInstrumentId); |
| 914 | setSavedWatchlistIds((current) => ({ ...current, [watchlistId]: (current[watchlistId] ?? []).filter((id) => id !== globalInstrumentId) })); |
| 915 | setWatchlists((current) => current.map((list) => list.watchlistId === watchlistId ? { ...list, instrumentCount: Math.max(0, list.instrumentCount - 1) } : list)); |
| 916 | setWatchlistRevision((value) => value + 1); |
| 917 | } catch (error) { |
| 918 | setWatchlistActionError(getApiFailure(error).message); |
| 919 | } finally { |
| 920 | watchlistMutationRef.current = false; |
| 921 | setWatchlistMutation(null); |
| 922 | } |
| 923 | } |
| 924 | |
| 925 | function selectResearchContext(value: string) { |
| 926 | setSelectedMarketIntelligenceStock(null); |
| 927 | setSelectedMarketIntelligenceRegion(null); |
| 928 | setResearchSummary(null); |
| 929 | setSelectedResearchInstrumentId(""); |
| 930 | if (value.startsWith("portfolio:")) { |
| 931 | const portfolioId = value.slice("portfolio:".length); |
| 932 | setResearchContext({ kind: "PORTFOLIO", portfolioId }); |
| 933 | rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId); |
| 934 | setSelectedPortfolioId(portfolioId); |
| 935 | return; |
| 936 | } |
| 937 | const watchlistId = value.slice("watchlist:".length); |
| 938 | const watchlist = watchlists.find((candidate) => candidate.watchlistId === watchlistId); |
| 939 | if (watchlist) { |
| 940 | setResearchContext({ |
| 941 | kind: "WATCHLIST", watchlistId: watchlist.watchlistId, |
| 942 | name: watchlist.name, region: watchlist.region, |
| 943 | }); |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | const [searchQuery, setSearchQuery] = useState(""); |
| 948 | const [searchResults, setSearchResults] = useState<ResearchInstrumentMatch[]>([]); |
| 949 | const [searchLoading, setSearchLoading] = useState(false); |
| 950 | const [searchError, setSearchError] = useState<string | null>(null); |
| 951 | const [searchPresentation, setSearchPresentation] = useState<PortfolioResearchCompany | null>(null); |
| 952 | const [searchSelectedMatch, setSearchSelectedMatch] = useState<ResearchInstrumentMatch | null>(null); |
| 953 | const searchWatchlistSaved = Boolean(searchSelectedMatch && watchlists.some((list) => |
| 954 | list.systemDefault && list.region === searchSelectedMatch.region |
| 955 | && (savedWatchlistIds[list.watchlistId] ?? []).includes(searchSelectedMatch.globalInstrumentId))); |
| 956 | const searchPresentationRequest = useRef(0); |
| 957 | const [searchWatchlistBusy, setSearchWatchlistBusy] = useState(false); |
| 958 | const [searchWatchlistError, setSearchWatchlistError] = useState<string | null>(null); |
| 959 | |
| 960 | async function loadSearchPresentation( |
| 961 | globalInstrumentId: string, _companyName: string, region: SectorPerformance["region"] |
| 962 | ) { |
| 963 | const requestId = ++searchPresentationRequest.current; |
| 964 | setSearchPresentation(null); |
| 965 | setResearchLoading(true); |
| 966 | try { |
| 967 | const presentation = await researchApi.getCompanyPresentation(globalInstrumentId, region); |
| 968 | if (requestId === searchPresentationRequest.current) setSearchPresentation(presentation); |
| 969 | } catch { |
| 970 | if (requestId === searchPresentationRequest.current) setSearchPresentation(null); |
| 971 | } finally { |
| 972 | if (requestId === searchPresentationRequest.current) setResearchLoading(false); |
| 973 | } |
| 974 | } |
| 975 | |
| 976 | function handleSearchSelect(match: ResearchInstrumentMatch) { |
| 977 | setSelectedMarketIntelligenceStock(null); |
| 978 | setSelectedMarketIntelligenceRegion(null); |
| 979 | setSearchQuery(""); |
| 980 | setSearchResults([]); |
| 981 | setSearchSelectedMatch(match); |
| 982 | setSelectedResearchInstrumentId(match.globalInstrumentId); |
| 983 | setResearchContext({ kind: "SEARCH", region: match.region, globalInstrumentId: match.globalInstrumentId }); |
| 984 | void openResearchReadiness(match.globalInstrumentId, match.companyName); |
| 985 | void loadSearchPresentation(match.globalInstrumentId, match.companyName, match.region); |
| 986 | } |
| 987 | |
| 988 | async function toggleSearchWatchlist() { |
| 989 | const match = searchSelectedMatch; |
| 990 | if (searchWatchlistBusy || !match?.globalInstrumentId?.trim()) return; |
| 991 | setSearchWatchlistBusy(true); |
| 992 | setSearchWatchlistError(null); |
| 993 | try { |
| 994 | const list = await researchApi.ensureDefaultWatchlist(match.region); |
| 995 | const already = (savedWatchlistIds[list.watchlistId] ?? []).includes(match.globalInstrumentId); |
| 996 | if (already) { |
| 997 | await researchApi.removeWatchlistInstrument(list.watchlistId, match.globalInstrumentId); |
| 998 | setSavedWatchlistIds((current) => ({ |
| 999 | ...current, |
| 1000 | [list.watchlistId]: (current[list.watchlistId] ?? []).filter((id) => id !== match.globalInstrumentId), |
| 1001 | })); |
| 1002 | } else { |
| 1003 | await researchApi.addWatchlistInstrument(list.watchlistId, { |
| 1004 | globalInstrumentId: match.globalInstrumentId, |
| 1005 | sourcePeriod: "DAY", |
| 1006 | sourcePerformancePct: null, |
| 1007 | }); |
| 1008 | setSavedWatchlistIds((current) => ({ |
| 1009 | ...current, |
| 1010 | [list.watchlistId]: [...(current[list.watchlistId] ?? []), match.globalInstrumentId], |
| 1011 | })); |
| 1012 | } |
| 1013 | setWatchlists((current) => current.some((item) => item.watchlistId === list.watchlistId) ? current : [...current, list]); |
| 1014 | } catch (error) { |
| 1015 | setSearchWatchlistError(getApiFailure(error).message); |
| 1016 | } finally { |
| 1017 | setSearchWatchlistBusy(false); |
| 1018 | } |
| 1019 | } |
| 1020 | |
| 1021 | async function createPortfolio() { |
| 1022 | setCreating(true); |
| 1023 | setError(null); |
| 1024 | try { |
| 1025 | const created = await portfolioApi.createPortfolio({ |
| 1026 | name: newPortfolioName.trim(), |
| 1027 | baseCurrency: newPortfolioCurrency.trim().toUpperCase() |
| 1028 | }); |
| 1029 | const loaded = await portfolioApi.listPortfolios(); |
| 1030 | setPortfolios(loaded); |
| 1031 | rememberSelectedPortfolioId(authenticatedUser?.userId, created.portfolioId); |
| 1032 | setSelectedPortfolioId(created.portfolioId); |
| 1033 | clearMarketIntelligenceContext(); |
| 1034 | setView("portfolio"); |
| 1035 | } catch (err) { |
| 1036 | setError(getApiFailure(err)); |
| 1037 | } finally { |
| 1038 | setCreating(false); |
| 1039 | } |
| 1040 | } |
| 1041 | |
| 1042 | async function syncPortfolio() { |
| 1043 | if (!selectedPortfolioId) { |
| 1044 | return; |
| 1045 | } |
| 1046 | |
| 1047 | setSyncing(true); |
| 1048 | setError(null); |
| 1049 | try { |
| 1050 | const syncedSummary = await portfolioApi.syncPortfolio(selectedPortfolioId); |
| 1051 | const loadedPositions = await portfolioApi.getPositions(selectedPortfolioId); |
| 1052 | const loaded = await portfolioApi.listPortfolios(); |
| 1053 | setPortfolios(loaded); |
| 1054 | setSummary(syncedSummary); |
| 1055 | setPositions(loadedPositions); |
| 1056 | } catch (err) { |
| 1057 | setError(getApiFailure(err)); |
| 1058 | } finally { |
| 1059 | setSyncing(false); |
| 1060 | } |
| 1061 | } |
| 1062 | |
| 1063 | async function syncSelectedPortfolio() { |
| 1064 | const brokerPortfolio = portfolios.find((portfolio) => portfolio.portfolioId === selectedPortfolioId); |
| 1065 | if (!brokerPortfolio?.brokerConnectionId) { |
| 1066 | return syncPortfolio(); |
| 1067 | } |
| 1068 | const connectionId = brokerPortfolio.brokerConnectionId; |
| 1069 | const brokerConnection = brokerConnections.find((connection) => connection.connectionId === connectionId); |
| 1070 | const brokerType = brokerConnection?.brokerType ?? brokerPortfolio.provider ?? "UNKNOWN"; |
| 1071 | const provider = brokerProviders.find((candidate) => candidate.brokerType === brokerType); |
| 1072 | setSyncing(true); |
| 1073 | setError(null); |
| 1074 | const authWindow = provider?.consumerAuthMode === "BROKER_REDIRECT" |
| 1075 | ? openBrokerAuthenticationWindow(brokerType) |
| 1076 | : null; |
| 1077 | try { |
| 1078 | const result = await portfolioApi.syncBrokerConnection(connectionId); |
| 1079 | if (result.status === "AUTHENTICATION_REQUIRED") { |
| 1080 | if (provider?.consumerAuthMode === "PARTNER_UNAVAILABLE") { |
| 1081 | setBrokerCardError(brokerType, "Direct customer account connection is not available yet."); |
| 1082 | return; |
| 1083 | } |
| 1084 | await completeBrokerAuthentication( |
| 1085 | authWindow, |
| 1086 | brokerType, |
| 1087 | () => brokerApi.authenticationAction(connectionId) |
| 1088 | ); |
| 1089 | return; |
| 1090 | } |
| 1091 | authWindow?.close(); |
| 1092 | const dashboard = await portfolioApi.getDashboard(); |
| 1093 | const [loadedSummary, loadedPositions, loadedHistory] = await Promise.all([ |
| 1094 | portfolioApi.getSummary(selectedPortfolioId), portfolioApi.getPositions(selectedPortfolioId), |
| 1095 | portfolioApi.getHistory(selectedPortfolioId, portfolioHistoryRange) |
| 1096 | ]); |
| 1097 | setPortfolioDashboard(dashboard); |
| 1098 | setPortfolios(dashboard.portfolios); |
| 1099 | setSummary(loadedSummary); |
| 1100 | setPositions(loadedPositions); |
| 1101 | setPortfolioHistory(loadedHistory); |
| 1102 | } catch (err) { |
| 1103 | authWindow?.close(); |
| 1104 | setError(getApiFailure(err)); |
| 1105 | } finally { |
| 1106 | setSyncing(false); |
| 1107 | } |
| 1108 | } |
| 1109 | |
| 1110 | async function refreshSelectedPrices() { |
| 1111 | if (!selectedPortfolioId) return; |
| 1112 | setSyncing(true); |
| 1113 | setError(null); |
| 1114 | try { |
| 1115 | await portfolioApi.refreshPrices(selectedPortfolioId); |
| 1116 | setPositions(await portfolioApi.getPositions(selectedPortfolioId)); |
| 1117 | } catch (err) { |
| 1118 | setError(getApiFailure(err)); |
| 1119 | } finally { |
| 1120 | setSyncing(false); |
| 1121 | } |
| 1122 | } |
| 1123 | |
| 1124 | function setBrokerCardError(brokerType: string, message: string | null) { |
| 1125 | setBrokerErrors((current) => { |
| 1126 | const next = { ...current }; |
| 1127 | if (message) next[brokerType] = message; |
| 1128 | else delete next[brokerType]; |
| 1129 | return next; |
| 1130 | }); |
| 1131 | } |
| 1132 | |
| 1133 | function openBrokerAuthenticationWindow(brokerType: string): Window | null { |
| 1134 | if (brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) { |
| 1135 | brokerAuthPopupRef.current.focus(); |
| 1136 | return null; |
| 1137 | } |
| 1138 | const width = 620; |
| 1139 | const height = 760; |
| 1140 | const left = Math.max(0, Math.round(window.screenX + (window.outerWidth - width) / 2)); |
| 1141 | const top = Math.max(0, Math.round(window.screenY + (window.outerHeight - height) / 2)); |
| 1142 | const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`; |
| 1143 | const authWindow = window.open("about:blank", "aip-ibkr-auth", features); |
| 1144 | if (!authWindow) { |
| 1145 | setBrokerCardError(brokerType, "Your browser blocked the IBKR sign-in window. Allow pop-ups for this site and try again."); |
| 1146 | return null; |
| 1147 | } |
| 1148 | brokerAuthPopupRef.current = authWindow; |
| 1149 | return authWindow; |
| 1150 | } |
| 1151 | |
| 1152 | function stopBrokerAuthenticationMonitoring(closePopup: boolean) { |
| 1153 | if (brokerAuthPollRef.current !== null) { |
| 1154 | window.clearInterval(brokerAuthPollRef.current); |
| 1155 | brokerAuthPollRef.current = null; |
| 1156 | } |
| 1157 | if (closePopup && brokerAuthPopupRef.current && !brokerAuthPopupRef.current.closed) { |
| 1158 | brokerAuthPopupRef.current.close(); |
| 1159 | } |
| 1160 | brokerAuthPopupRef.current = null; |
| 1161 | brokerAuthPollBusyRef.current = false; |
| 1162 | pendingBrokerAuthRef.current = null; |
| 1163 | brokerAuthStartedAtRef.current = 0; |
| 1164 | window.localStorage.removeItem("aip.pendingBrokerAuthentication"); |
| 1165 | setAuthenticatingBroker(null); |
| 1166 | setBrokerAuthenticationTimedOut(false); |
| 1167 | } |
| 1168 | |
| 1169 | async function refreshAuthenticatedBroker(connectionId: string): Promise<boolean> { |
| 1170 | const authStatus = await brokerApi.getAuthStatus(connectionId); |
| 1171 | if (!authStatus.authenticated || authStatus.state !== "CONNECTED") return false; |
| 1172 | if (pendingBrokerAuthRef.current?.provider === "IBKR") { |
| 1173 | await bootstrapIbkrPortfolio(connectionId); |
| 1174 | } |
| 1175 | const [connections, dashboard] = await Promise.all([ |
| 1176 | brokerApi.listConnections(), |
| 1177 | portfolioApi.getDashboard() |
| 1178 | ]); |
| 1179 | setBrokerConnections(connections); |
| 1180 | setPortfolioDashboard(dashboard); |
| 1181 | setPortfolios(dashboard.portfolios); |
| 1182 | const linkedPortfolio = dashboard.portfolios.find((portfolio) => |
| 1183 | portfolio.brokerConnectionId === connectionId && portfolio.portfolioId === selectedPortfolioId |
| 1184 | ); |
| 1185 | if (linkedPortfolio) { |
| 1186 | const [loadedPortfolio, loadedSummary, loadedPositions, loadedHistory] = await Promise.all([ |
| 1187 | portfolioApi.getPortfolio(linkedPortfolio.portfolioId), |
| 1188 | portfolioApi.getSummary(linkedPortfolio.portfolioId), |
| 1189 | portfolioApi.getPositions(linkedPortfolio.portfolioId), |
| 1190 | portfolioApi.getHistory(linkedPortfolio.portfolioId, portfolioHistoryRange) |
| 1191 | ]); |
| 1192 | setSelectedPortfolio(loadedPortfolio); |
| 1193 | setSummary(loadedSummary); |
| 1194 | setPositions(loadedPositions); |
| 1195 | setPortfolioHistory(loadedHistory); |
| 1196 | } |
| 1197 | return true; |
| 1198 | } |
| 1199 | |
| 1200 | async function bootstrapIbkrPortfolio(connectionId: string): Promise<boolean> { |
| 1201 | const existing = ibkrBootstrapSyncsRef.current.get(connectionId); |
| 1202 | if (existing) return existing; |
| 1203 | const sync = (async () => { |
| 1204 | try { |
| 1205 | const result = await portfolioApi.syncBrokerConnection(connectionId); |
| 1206 | if (result.status !== "CONNECTED") { |
| 1207 | throw new Error("Broker portfolio synchronization did not complete."); |
| 1208 | } |
| 1209 | const dashboard = await portfolioApi.getDashboard(); |
| 1210 | setPortfolioDashboard(dashboard); |
| 1211 | setPortfolios(dashboard.portfolios); |
| 1212 | const returnedPortfolioId = result.portfolios.find((portfolio) => |
| 1213 | portfolio.brokerConnectionId === connectionId |
| 1214 | )?.portfolioId; |
| 1215 | const portfolioId = returnedPortfolioId ?? dashboard.portfolios.find((portfolio) => |
| 1216 | portfolio.brokerConnectionId === connectionId |
| 1217 | )?.portfolioId; |
| 1218 | if (portfolioId) { |
| 1219 | setSelectedPortfolioId((current) => { |
| 1220 | if (current) return current; |
| 1221 | rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId); |
| 1222 | return portfolioId; |
| 1223 | }); |
| 1224 | } |
| 1225 | setBrokerCardError("IBKR", null); |
| 1226 | return true; |
| 1227 | } catch { |
| 1228 | setBrokerCardError("IBKR", "Interactive Brokers connected, but portfolio sync failed. Try syncing again."); |
| 1229 | return false; |
| 1230 | } |
| 1231 | })(); |
| 1232 | ibkrBootstrapSyncsRef.current.set(connectionId, sync); |
| 1233 | return sync; |
| 1234 | } |
| 1235 | |
| 1236 | async function finishBrokerAuthentication(connectionId: string) { |
| 1237 | if (brokerAuthPollBusyRef.current) return; |
| 1238 | brokerAuthPollBusyRef.current = true; |
| 1239 | try { |
| 1240 | if (await refreshAuthenticatedBroker(connectionId)) { |
| 1241 | stopBrokerAuthenticationMonitoring(true); |
| 1242 | } |
| 1243 | } catch { |
| 1244 | // Authentication may still be propagating from IBKR; the bounded parent poll remains authoritative. |
| 1245 | } finally { |
| 1246 | brokerAuthPollBusyRef.current = false; |
| 1247 | } |
| 1248 | } |
| 1249 | |
| 1250 | function startBrokerAuthenticationMonitoring(authWindow: Window, connectionId: string, provider: string) { |
| 1251 | if (provider === "IBKR") { |
| 1252 | ibkrBootstrapSyncsRef.current.delete(connectionId); |
| 1253 | } |
| 1254 | pendingBrokerAuthRef.current = { connectionId, provider }; |
| 1255 | setAuthenticatingBroker(provider); |
| 1256 | setBrokerAuthenticationTimedOut(false); |
| 1257 | brokerAuthStartedAtRef.current = Date.now(); |
| 1258 | startBrokerAuthenticationPolling(authWindow, connectionId); |
| 1259 | } |
| 1260 | |
| 1261 | function startBrokerAuthenticationPolling(authWindow: Window, connectionId: string) { |
| 1262 | if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current); |
| 1263 | const poll = async () => { |
| 1264 | if (brokerAuthPollBusyRef.current) return; |
| 1265 | if (Date.now() - brokerAuthStartedAtRef.current >= brokerAuthenticationTimeoutMs) { |
| 1266 | if (brokerAuthPollRef.current !== null) window.clearInterval(brokerAuthPollRef.current); |
| 1267 | brokerAuthPollRef.current = null; |
| 1268 | setBrokerAuthenticationTimedOut(true); |
| 1269 | return; |
| 1270 | } |
| 1271 | if (authWindow.closed) { |
| 1272 | brokerAuthPollBusyRef.current = true; |
| 1273 | try { |
| 1274 | await refreshAuthenticatedBroker(connectionId); |
| 1275 | } catch { |
| 1276 | // A manually closed pending login remains unauthenticated. |
| 1277 | } finally { |
| 1278 | brokerAuthPollBusyRef.current = false; |
| 1279 | stopBrokerAuthenticationMonitoring(false); |
| 1280 | } |
| 1281 | return; |
| 1282 | } |
| 1283 | await finishBrokerAuthentication(connectionId); |
| 1284 | }; |
| 1285 | brokerAuthPollRef.current = window.setInterval(() => void poll(), brokerAuthenticationPollMs); |
| 1286 | void poll(); |
| 1287 | } |
| 1288 | |
| 1289 | function continueBrokerAuthenticationChecking() { |
| 1290 | const pending = pendingBrokerAuthRef.current; |
| 1291 | const authWindow = brokerAuthPopupRef.current; |
| 1292 | if (!pending || !authWindow || authWindow.closed) return; |
| 1293 | setBrokerAuthenticationTimedOut(false); |
| 1294 | brokerAuthStartedAtRef.current = Date.now(); |
| 1295 | startBrokerAuthenticationPolling(authWindow, pending.connectionId); |
| 1296 | } |
| 1297 | |
| 1298 | function cancelBrokerAuthentication() { |
| 1299 | stopBrokerAuthenticationMonitoring(true); |
| 1300 | } |
| 1301 | |
| 1302 | async function completeBrokerAuthentication( |
| 1303 | authWindow: Window | null, |
| 1304 | brokerType: string, |
| 1305 | requestAction: () => ReturnType<typeof brokerApi.authenticationAction> |
| 1306 | ): Promise<boolean> { |
| 1307 | if (!authWindow) return false; |
| 1308 | try { |
| 1309 | const action = await requestAction(); |
| 1310 | if (action.action === "REDIRECT_REQUIRED" || action.action === "POPUP_REQUIRED") { |
| 1311 | if (!action.authenticationUrl) { |
| 1312 | stopBrokerAuthenticationMonitoring(true); |
| 1313 | setBrokerCardError(brokerType, "Broker authentication is temporarily unavailable. Your saved portfolio remains available."); |
| 1314 | return false; |
| 1315 | } |
| 1316 | window.localStorage.setItem("aip.pendingBrokerAuthentication", JSON.stringify({ |
| 1317 | connectionId: action.connectionId, provider: action.provider |
| 1318 | })); |
| 1319 | authWindow.location.assign(action.authenticationUrl); |
| 1320 | setBrokerCardError(brokerType, null); |
| 1321 | startBrokerAuthenticationMonitoring(authWindow, action.connectionId, action.provider); |
| 1322 | return true; |
| 1323 | } |
| 1324 | authWindow.close(); |
| 1325 | if (action.action === "NONE") { |
| 1326 | if (brokerType === "IBKR" && action.status === "CONNECTED") { |
| 1327 | ibkrBootstrapSyncsRef.current.delete(action.connectionId); |
| 1328 | await bootstrapIbkrPortfolio(action.connectionId); |
| 1329 | } |
| 1330 | setBrokerConnections(await brokerApi.listConnections()); |
| 1331 | } else { |
| 1332 | setBrokerCardError(brokerType, action.message); |
| 1333 | } |
| 1334 | stopBrokerAuthenticationMonitoring(false); |
| 1335 | return false; |
| 1336 | } catch (err) { |
| 1337 | stopBrokerAuthenticationMonitoring(true); |
| 1338 | setBrokerCardError(brokerType, getApiFailure(err).message); |
| 1339 | return false; |
| 1340 | } |
| 1341 | } |
| 1342 | |
| 1343 | async function login(userKey: "user-a" | "user-b") { |
| 1344 | setAuthLoading(true); |
| 1345 | setError(null); |
| 1346 | try { |
| 1347 | const session = await authApi.loginDev(userKey); |
| 1348 | window.localStorage.setItem("aip.accessToken", session.accessToken); |
| 1349 | window.localStorage.setItem("aip.user", JSON.stringify(session.user)); |
| 1350 | // Authentication can complete in a tab that was opened before a frontend rollout. |
| 1351 | // Reload the root document so Dashboard effects always mount from the active build. |
| 1352 | window.location.replace("/"); |
| 1353 | } catch (err) { |
| 1354 | setError(getApiFailure(err)); |
| 1355 | } finally { |
| 1356 | setAuthLoading(false); |
| 1357 | } |
| 1358 | } |
| 1359 | |
| 1360 | async function loginEmail(email: string, password: string) { |
| 1361 | setAuthLoading(true); setError(null); |
| 1362 | try { |
| 1363 | const session = await authApi.login(email, password); |
| 1364 | window.localStorage.setItem("aip.accessToken", session.accessToken); |
| 1365 | window.localStorage.setItem("aip.user", JSON.stringify(session.user)); |
| 1366 | window.location.replace("/"); |
| 1367 | } catch (err) { setError(getApiFailure(err)); } finally { setAuthLoading(false); } |
| 1368 | } |
| 1369 | |
| 1370 | function logout() { |
| 1371 | window.localStorage.removeItem("aip.accessToken"); |
| 1372 | window.localStorage.removeItem("aip.user"); |
| 1373 | window.history.replaceState({}, "", "/"); |
| 1374 | setError(null); |
| 1375 | setAccessToken(null); |
| 1376 | setAuthenticatedUser(null); |
| 1377 | clearUserScopedState(); |
| 1378 | } |
| 1379 | |
| 1380 | if (authLoading) { |
| 1381 | return <LoadingView />; |
| 1382 | } |
| 1383 | |
| 1384 | if (!accessToken || !authenticatedUser) { |
| 1385 | return <SignInView error={error} onLogin={login} onEmailLogin={loginEmail} />; |
| 1386 | } |
| 1387 | |
| 1388 | return ( |
| 1389 | <main className="app-shell"> |
| 1390 | <aside className={`sidebar ${sidebarOpen ? "sidebar-open" : ""}`}> |
| 1391 | <div className="brand-lockup"> |
| 1392 | <div className="brand-mark">AI</div> |
| 1393 | <div> |
| 1394 | <strong>AI Investment</strong> |
| 1395 | <span>Intelligence</span> |
| 1396 | </div> |
| 1397 | </div> |
| 1398 | |
| 1399 | <nav aria-label="Primary"> |
| 1400 | {navItems.map((item) => { |
| 1401 | const Icon = item.icon; |
| 1402 | return ( |
| 1403 | <button |
| 1404 | className={`nav-item ${view === item.id ? "nav-item-active" : ""}`} |
| 1405 | key={item.id} |
| 1406 | onClick={() => { |
| 1407 | clearMarketIntelligenceContext(); |
| 1408 | setView(item.id); |
| 1409 | setSidebarOpen(false); |
| 1410 | }} |
| 1411 | type="button" |
| 1412 | > |
| 1413 | <Icon size={18} aria-hidden="true" /> |
| 1414 | {item.label} |
| 1415 | </button> |
| 1416 | ); |
| 1417 | })} |
| 1418 | </nav> |
| 1419 | |
| 1420 | <div className="sidebar-panel"> |
| 1421 | <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "info"}> |
| 1422 | {portfolioSourceLabels(positions).badge} |
| 1423 | </Badge> |
| 1424 | <p>{hasRealBrokerPositions(positions) ? "Read-only broker data is sourced from Interactive Brokers." : "Demo portfolios use generated broker data."}</p> |
| 1425 | </div> |
| 1426 | </aside> |
| 1427 | |
| 1428 | <section className="workspace"> |
| 1429 | <header className="topbar"> |
| 1430 | <Button className="mobile-menu" variant="ghost" onClick={() => setSidebarOpen(true)} aria-label="Open navigation"> |
| 1431 | <Menu size={20} /> |
| 1432 | </Button> |
| 1433 | <div className="command-bar"> |
| 1434 | <Command size={17} aria-hidden="true" /> |
| 1435 | <input |
| 1436 | aria-label="Global security search" |
| 1437 | placeholder="Search ticker, company, or ISIN" |
| 1438 | type="search" |
| 1439 | /> |
| 1440 | <kbd>/</kbd> |
| 1441 | </div> |
| 1442 | <div className="topbar-actions"> |
| 1443 | <Badge tone={hasRealBrokerPositions(positions) ? "positive" : "warning"}> |
| 1444 | {portfolioSourceLabels(positions).badge} |
| 1445 | </Badge> |
| 1446 | <button className="icon-button" type="button" aria-label="Notifications"> |
| 1447 | <Bell size={18} /> |
| 1448 | </button> |
| 1449 | <label className="theme-switch"> |
| 1450 | <span className="sr-only">Theme</span> |
| 1451 | <Sun size={16} aria-hidden="true" /> |
| 1452 | <select value={theme} onChange={(event) => setTheme(event.target.value as Theme)}> |
| 1453 | <option value="system">System</option> |
| 1454 | <option value="light">Light</option> |
| 1455 | <option value="dark">Dark</option> |
| 1456 | </select> |
| 1457 | <Moon size={16} aria-hidden="true" /> |
| 1458 | </label> |
| 1459 | <button className="account-button" type="button"> |
| 1460 | <span>{authenticatedUser.displayName ?? authenticatedUser.email ?? "Account"}</span> |
| 1461 | <ChevronDown size={16} aria-hidden="true" /> |
| 1462 | </button> |
| 1463 | <Button variant="secondary" onClick={logout}> |
| 1464 | Logout |
| 1465 | </Button> |
| 1466 | </div> |
| 1467 | </header> |
| 1468 | |
| 1469 | {sidebarOpen ? ( |
| 1470 | <button className="sidebar-scrim" onClick={() => setSidebarOpen(false)} aria-label="Close navigation" type="button"> |
| 1471 | <X size={20} /> |
| 1472 | </button> |
| 1473 | ) : null} |
| 1474 | |
| 1475 | <div className="content"> |
| 1476 | <section className="page-header"> |
| 1477 | <div> |
| 1478 | <p className="eyebrow">{view === "research" ? "Research intelligence" : "Brokers & Portfolios"}</p> |
| 1479 | <h1>{view === "dashboard" ? "Portfolio command center" : navItems.find((item) => item.id === view)?.label}</h1> |
| 1480 | <p> |
| 1481 | Backend: <code>{frontendConfig.apiBaseUrl || "same-origin gateway"}</code> |
| 1482 | </p> |
| 1483 | </div> |
| 1484 | {view === "research" ? ( |
| 1485 | null |
| 1486 | ) : ( |
| 1487 | <PortfolioSelector |
| 1488 | portfolios={portfolios} |
| 1489 | selectedPortfolioId={selectedPortfolioId} |
| 1490 | onChange={(portfolioId) => { |
| 1491 | setPortfolioResearchSummary(null); |
| 1492 | setSelectedResearchInstrumentId(""); |
| 1493 | clearMarketIntelligenceContext(); |
| 1494 | setResearchSummary(null); |
| 1495 | setPortfolioHistory(null); |
| 1496 | rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId); |
| 1497 | setSelectedPortfolioId(portfolioId); |
| 1498 | }} |
| 1499 | /> |
| 1500 | )} |
| 1501 | </section> |
| 1502 | |
| 1503 | {error ? ( |
| 1504 | <ErrorState |
| 1505 | message={error.message} |
| 1506 | correlationId={error.correlationId} |
| 1507 | action={ |
| 1508 | <Button variant="secondary" onClick={() => window.location.reload()}> |
| 1509 | Retry |
| 1510 | </Button> |
| 1511 | } |
| 1512 | /> |
| 1513 | ) : null} |
| 1514 | |
| 1515 | {view !== "research" && watchlistActionError ? <p role="alert">{watchlistActionError}</p> : null} |
| 1516 | {view === "backtesting" ? <Backtesting /> : null} |
| 1517 | {view === "dashboard" ? <OpportunityRadar heldIds={positions.flatMap(p => p.instrument.globalInstrumentId ? [p.instrument.globalInstrumentId] : [])} watchlistedIds={Object.values(savedWatchlistIds).flat()} /> : null} |
| 1518 | {loading ? <LoadingView /> : null} |
| 1519 | |
| 1520 | {!loading && !error ? ( |
| 1521 | <> |
| 1522 | {view === "dashboard" ? ( |
| 1523 | <> |
| 1524 | <PortfolioTabs |
| 1525 | portfolios={portfolios} |
| 1526 | selected={portfolioScope} |
| 1527 | onSelect={(value) => { |
| 1528 | setPortfolioScope(value); |
| 1529 | if (value !== "ALL") setSelectedPortfolioId(value); |
| 1530 | }} |
| 1531 | /> |
| 1532 | {portfolioScope === "ALL" ? ( |
| 1533 | <MultiPortfolioDashboard |
| 1534 | dashboard={portfolioDashboard} |
| 1535 | onAddWatchlist={(selection) => { void addRankedStockToWatchlist(selection); }} |
| 1536 | savedInstrumentIds={watchlists.filter((list) => list.region === sectorPerformanceRegion && list.systemDefault).flatMap((list) => savedWatchlistIds[list.watchlistId] ?? [])} |
| 1537 | watchlistBusy={watchlistMutation !== null} |
| 1538 | performance={sectorPerformance} |
| 1539 | performanceRegion={sectorPerformanceRegion} |
| 1540 | performanceSector={sectorPerformanceSector} |
| 1541 | performancePeriod={sectorPerformancePeriod} |
| 1542 | sectorOptions={sectorOptions} |
| 1543 | sectorOptionsLoading={sectorOptionsLoading} |
| 1544 | onPerformanceRegion={(region) => { |
| 1545 | if (selectedMarketIntelligenceRegion && selectedMarketIntelligenceRegion !== region) { |
| 1546 | clearMarketIntelligenceContext(); |
| 1547 | } |
| 1548 | setSectorPerformanceRegion(region); |
| 1549 | }} |
| 1550 | onPerformanceSector={setSectorPerformanceSector} |
| 1551 | onPerformancePeriod={setSectorPerformancePeriod} |
| 1552 | onCreate={createPortfolio} |
| 1553 | creating={creating} |
| 1554 | selectedResearchInstrumentId={selectedResearchInstrumentId} |
| 1555 | onOpenResearch={(selection) => { void openMarketIntelligenceResearch(selection); }} |
| 1556 | /> |
| 1557 | ) : ( |
| 1558 | <DashboardView |
| 1559 | portfolio={selectedPortfolio} |
| 1560 | summary={summary} |
| 1561 | positions={positions} |
| 1562 | onCreate={createPortfolio} |
| 1563 | onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined} |
| 1564 | onRefreshPrices={selectedPortfolio?.acquisitionSource === "MANUAL_CSV_IMPORT" ? refreshSelectedPrices : undefined} |
| 1565 | creating={creating} |
| 1566 | syncing={syncing} |
| 1567 | newPortfolioName={newPortfolioName} |
| 1568 | newPortfolioCurrency={newPortfolioCurrency} |
| 1569 | setNewPortfolioName={setNewPortfolioName} |
| 1570 | setNewPortfolioCurrency={setNewPortfolioCurrency} |
| 1571 | /> |
| 1572 | )} |
| 1573 | </> |
| 1574 | ) : null} |
| 1575 | {view === "portfolio" ? ( |
| 1576 | <PortfolioView |
| 1577 | portfolio={selectedPortfolio} |
| 1578 | summary={summary} |
| 1579 | positions={sortedPositions} |
| 1580 | rawPositions={positions} |
| 1581 | history={portfolioHistory} |
| 1582 | historyRange={portfolioHistoryRange} |
| 1583 | historyLoading={portfolioHistoryLoading} |
| 1584 | onHistoryRange={setPortfolioHistoryRange} |
| 1585 | searchText={searchText} |
| 1586 | sortKey={sortKey} |
| 1587 | onSearch={setSearchText} |
| 1588 | onSort={setSortKey} |
| 1589 | onCreate={createPortfolio} |
| 1590 | onSync={selectedPortfolio?.brokerConnectionId ? syncSelectedPortfolio : undefined} |
| 1591 | onRefreshPrices={selectedPortfolio?.acquisitionSource === "MANUAL_CSV_IMPORT" ? refreshSelectedPrices : undefined} |
| 1592 | creating={creating} |
| 1593 | syncing={syncing} |
| 1594 | newPortfolioName={newPortfolioName} |
| 1595 | newPortfolioCurrency={newPortfolioCurrency} |
| 1596 | setNewPortfolioName={setNewPortfolioName} |
| 1597 | setNewPortfolioCurrency={setNewPortfolioCurrency} |
| 1598 | onUpdateDisplayName={updateHoldingDisplayName} |
| 1599 | portfolioResearch={portfolioResearchSummary} |
| 1600 | /> |
| 1601 | ) : null} |
| 1602 | {view === "brokers" ? ( |
| 1603 | <BrokerView |
| 1604 | providers={brokerProviders} |
| 1605 | connections={brokerConnections} |
| 1606 | portfolios={portfolios} |
| 1607 | errors={brokerErrors} |
| 1608 | loading={brokerLoading} |
| 1609 | authenticatingBroker={authenticatingBroker} |
| 1610 | authenticationTimedOut={brokerAuthenticationTimedOut} |
| 1611 | onContinueAuthentication={continueBrokerAuthenticationChecking} |
| 1612 | onCancelAuthentication={cancelBrokerAuthentication} |
| 1613 | onConnectBroker={async (provider) => { |
| 1614 | if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") { |
| 1615 | setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet."); |
| 1616 | return; |
| 1617 | } |
| 1618 | const authWindow = openBrokerAuthenticationWindow(provider.brokerType); |
| 1619 | if (!authWindow) return; |
| 1620 | setBrokerLoading(true); |
| 1621 | try { |
| 1622 | const connection = await brokerApi.connectBroker(provider.brokerType); |
| 1623 | await completeBrokerAuthentication( |
| 1624 | authWindow, |
| 1625 | provider.brokerType, |
| 1626 | () => brokerApi.authenticationAction(connection.connectionId) |
| 1627 | ); |
| 1628 | setBrokerConnections(await brokerApi.listConnections()); |
| 1629 | setBrokerProviders(await brokerApi.listBrokers()); |
| 1630 | } catch (err) { |
| 1631 | stopBrokerAuthenticationMonitoring(true); |
| 1632 | setBrokerCardError(provider.brokerType, getApiFailure(err).message); |
| 1633 | } finally { |
| 1634 | setBrokerLoading(false); |
| 1635 | } |
| 1636 | }} |
| 1637 | onAuthenticate={async (connectionId, provider) => { |
| 1638 | if (provider.consumerAuthMode === "PARTNER_UNAVAILABLE") { |
| 1639 | setBrokerCardError(provider.brokerType, "Direct customer account connection is not available yet."); |
| 1640 | return; |
| 1641 | } |
| 1642 | const authWindow = openBrokerAuthenticationWindow(provider.brokerType); |
| 1643 | if (!authWindow) return; |
| 1644 | setBrokerLoading(true); |
| 1645 | try { |
| 1646 | await completeBrokerAuthentication( |
| 1647 | authWindow, |
| 1648 | provider.brokerType, |
| 1649 | () => brokerApi.authenticationAction(connectionId) |
| 1650 | ); |
| 1651 | setBrokerConnections(await brokerApi.listConnections()); |
| 1652 | } catch (err) { |
| 1653 | stopBrokerAuthenticationMonitoring(true); |
| 1654 | setBrokerCardError(provider.brokerType, getApiFailure(err).message); |
| 1655 | } finally { |
| 1656 | setBrokerLoading(false); |
| 1657 | } |
| 1658 | }} |
| 1659 | onConfigureIndividual={async (provider, clientKey, clientSecret) => { |
| 1660 | setBrokerLoading(true); |
| 1661 | try { |
| 1662 | const existing = brokerConnections.find((connection) => connection.brokerType === provider.brokerType); |
| 1663 | const connection = existing ?? await brokerApi.connectBroker(provider.brokerType); |
| 1664 | await brokerApi.configureCredentials(connection.connectionId, clientKey, clientSecret); |
| 1665 | setBrokerCardError(provider.brokerType, null); |
| 1666 | setBrokerConnections(await brokerApi.listConnections()); |
| 1667 | } catch (err) { |
| 1668 | setBrokerCardError(provider.brokerType, getApiFailure(err).message); |
| 1669 | throw err; |
| 1670 | } finally { |
| 1671 | setBrokerLoading(false); |
| 1672 | } |
| 1673 | }} |
| 1674 | onImportComplete={async (portfolioId) => { |
| 1675 | const dashboard = await portfolioApi.getDashboard(); |
| 1676 | setPortfolioDashboard(dashboard); |
| 1677 | setPortfolios(dashboard.portfolios); |
| 1678 | setSelectedPortfolioId(portfolioId); |
| 1679 | setPortfolioScope(portfolioId); |
| 1680 | rememberSelectedPortfolioId(authenticatedUser?.userId, portfolioId); |
| 1681 | clearMarketIntelligenceContext(); |
| 1682 | setView("portfolio"); |
| 1683 | }} |
| 1684 | onDisconnect={async (connectionId) => { |
| 1685 | setBrokerLoading(true); |
| 1686 | try { |
| 1687 | await brokerApi.disconnectConnection(connectionId); |
| 1688 | setBrokerConnections(await brokerApi.listConnections()); |
| 1689 | setBrokerProviders(await brokerApi.listBrokers()); |
| 1690 | } finally { |
| 1691 | setBrokerLoading(false); |
| 1692 | } |
| 1693 | }} |
| 1694 | /> |
| 1695 | ) : null} |
| 1696 | {view === "research" ? ( |
| 1697 | <> |
| 1698 | <Card className="wide-panel research-discovery"> |
| 1699 | <h2>Research intelligence</h2> |
| 1700 | <StockSearchField selectedGlobalInstrumentId={selectedResearchInstrumentId} onSelect={handleSearchSelect} /> |
| 1701 | </Card> |
| 1702 | <Card className="wide-panel research-saved-lists"> |
| 1703 | <h3>Saved lists</h3> |
| 1704 | <div className="button-row" role="group" aria-label="Regional watchlists"> |
| 1705 | {(["INDIA", "USA", "EUROPE"] as const).map((region) => <Button key={region} variant="secondary" onClick={() => { void openRegionalWatchlist(region); }}>{regionalWatchlistName(region)}</Button>)} |
| 1706 | </div> |
| 1707 | <ResearchContextSelector |
| 1708 | portfolios={portfolios} |
| 1709 | watchlists={watchlists} |
| 1710 | context={researchContext} |
| 1711 | onChange={selectResearchContext} |
| 1712 | /> |
| 1713 | {watchlistActionError ? <p role="alert">{watchlistActionError}</p> : null} |
| 1714 | {researchContext.kind === "WATCHLIST" && selectedResearchInstrumentId ? <Button variant="secondary" disabled={watchlistMutation !== null} onClick={() => { void removeWatchlistStock(selectedResearchInstrumentId); }}>Remove selected company from {researchContext.name}</Button> : null} |
| 1715 | </Card> |
| 1716 | <ResearchView |
| 1717 | positions={positions} |
| 1718 | portfolioResearchSummary={portfolioResearchSummary} |
| 1719 | portfolioResearchLoading={portfolioResearchLoading} |
| 1720 | portfolioResearchError={portfolioResearchError} |
| 1721 | researchContext={researchContext} |
| 1722 | watchlistResearch={watchlistResearch} |
| 1723 | watchlistResearchLoading={watchlistResearchLoading} |
| 1724 | watchlistResearchError={watchlistResearchError} |
| 1725 | selectedResearchInstrumentId={selectedResearchInstrumentId} |
| 1726 | onSelectInstrument={setSelectedResearchInstrumentId} |
| 1727 | searchPresentation={searchPresentation} |
| 1728 | onSearchSelect={handleSearchSelect} |
| 1729 | searchSelectedMatch={searchSelectedMatch} |
| 1730 | searchWatchlistSaved={searchWatchlistSaved} |
| 1731 | searchWatchlistBusy={searchWatchlistBusy} |
| 1732 | searchWatchlistError={searchWatchlistError} |
| 1733 | onToggleSearchWatchlist={toggleSearchWatchlist} |
| 1734 | summary={researchSummary} |
| 1735 | loading={researchLoading} |
| 1736 | eventType={researchEventType} |
| 1737 | impact={researchImpact} |
| 1738 | onEventType={setResearchEventType} |
| 1739 | onImpact={setResearchImpact} |
| 1740 | onRefresh={async () => { |
| 1741 | if (!selectedResearchInstrumentId) { |
| 1742 | return; |
| 1743 | } |
| 1744 | const selectedCompany = portfolioResearchSummary?.companies.find( |
| 1745 | (company) => company.instrumentId === selectedResearchInstrumentId |
| 1746 | ); |
| 1747 | await openResearchReadiness( |
| 1748 | selectedResearchInstrumentId, |
| 1749 | selectedCompany?.companyName ?? researchSummary?.profile.companyName ?? "Company research" |
| 1750 | ); |
| 1751 | }} |
| 1752 | /> |
| 1753 | </> |
| 1754 | ) : null} |
| 1755 | {view === "settings" ? <SettingsView /> : null} |
| 1756 | </> |
| 1757 | ) : null} |
| 1758 | </div> |
| 1759 | </section> |
| 1760 | {researchReadinessDialog ? ( |
| 1761 | <ResearchReadinessDialog |
| 1762 | companyName={researchReadinessDialog.companyName} |
| 1763 | readiness={researchReadiness} |
| 1764 | loading={researchReadinessLoading} |
| 1765 | error={researchReadinessError} |
| 1766 | ensuringRequirementIds={ensuringResearchRequirements} |
| 1767 | analysis={stockRuleEngineAnalysis} |
| 1768 | analysisLoading={stockRuleEngineLoading} |
| 1769 | analysisError={stockRuleEngineError} |
| 1770 | onClose={() => { |
| 1771 | setResearchReadinessDialog(null); |
| 1772 | setResearchReadiness(null); |
| 1773 | setResearchReadinessError(null); |
| 1774 | setStockRuleEngineAnalysis(null); |
| 1775 | setStockRuleEngineError(null); |
| 1776 | }} |
| 1777 | onFindData={(requirements) => { void findResearchData(requirements); }} |
| 1778 | onRunAnalysis={(allowPartial) => { void runStockRuleEngineAnalysis(allowPartial); }} |
| 1779 | /> |
| 1780 | ) : null} |
| 1781 | </main> |
| 1782 | ); |
| 1783 | } |
| 1784 | |
| 1785 | function ResearchReadinessDialog({ |
| 1786 | companyName, |
| 1787 | readiness, |
| 1788 | loading, |
| 1789 | error, |
| 1790 | ensuringRequirementIds, |
| 1791 | analysis, |
| 1792 | analysisLoading, |
| 1793 | analysisError, |
| 1794 | onClose, |
| 1795 | onFindData, |
| 1796 | onRunAnalysis |
| 1797 | }: { |
| 1798 | companyName: string; |
| 1799 | readiness: ResearchReadiness | null; |
| 1800 | loading: boolean; |
| 1801 | error: string | null; |
| 1802 | ensuringRequirementIds: string[]; |
| 1803 | analysis: StockRuleEngineAnalysis | null; |
| 1804 | analysisLoading: boolean; |
| 1805 | analysisError: string | null; |
| 1806 | onClose: () => void; |
| 1807 | onFindData: (requirements: string[]) => void; |
| 1808 | onRunAnalysis: (allowPartial: boolean) => void; |
| 1809 | }) { |
| 1810 | const dialogRef = useRef<HTMLElement>(null); |
| 1811 | const findable = readiness?.requirements.filter((item) => |
| 1812 | item.supportedActions.includes("FIND_DATA") && item.status !== "READY_FRESH" |
| 1813 | ) ?? []; |
| 1814 | |
| 1815 | useEffect(() => { |
| 1816 | const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; |
| 1817 | const appShell = document.querySelector<HTMLElement>(".app-shell"); |
| 1818 | const previousBodyOverflow = document.body.style.overflow; |
| 1819 | const previousBodyPaddingRight = document.body.style.paddingRight; |
| 1820 | const previousAppInert = appShell?.inert ?? false; |
| 1821 | const previousAppAriaHidden = appShell?.getAttribute("aria-hidden") ?? null; |
| 1822 | const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; |
| 1823 | |
| 1824 | document.body.style.overflow = "hidden"; |
| 1825 | if (scrollbarWidth > 0) { |
| 1826 | const currentPadding = Number.parseFloat(window.getComputedStyle(document.body).paddingRight) || 0; |
| 1827 | document.body.style.paddingRight = `${currentPadding + scrollbarWidth}px`; |
| 1828 | } |
| 1829 | if (appShell) { |
| 1830 | appShell.inert = true; |
| 1831 | appShell.setAttribute("aria-hidden", "true"); |
| 1832 | } |
| 1833 | dialogRef.current?.querySelector<HTMLElement>("[data-readiness-close]")?.focus(); |
| 1834 | |
| 1835 | return () => { |
| 1836 | document.body.style.overflow = previousBodyOverflow; |
| 1837 | document.body.style.paddingRight = previousBodyPaddingRight; |
| 1838 | if (appShell) { |
| 1839 | appShell.inert = previousAppInert; |
| 1840 | if (previousAppAriaHidden === null) appShell.removeAttribute("aria-hidden"); |
| 1841 | else appShell.setAttribute("aria-hidden", previousAppAriaHidden); |
| 1842 | } |
| 1843 | previouslyFocused?.focus(); |
| 1844 | }; |
| 1845 | }, []); |
| 1846 | |
| 1847 | useEffect(() => { |
| 1848 | function handleKeyDown(event: KeyboardEvent) { |
| 1849 | if (event.key === "Escape") { |
| 1850 | event.preventDefault(); |
| 1851 | onClose(); |
| 1852 | return; |
| 1853 | } |
| 1854 | if (event.key !== "Tab" || !dialogRef.current) return; |
| 1855 | const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>( |
| 1856 | "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" |
| 1857 | )).filter((element) => !element.hidden && element.getClientRects().length > 0); |
| 1858 | if (!focusable.length) { |
| 1859 | event.preventDefault(); |
| 1860 | dialogRef.current.focus(); |
| 1861 | return; |
| 1862 | } |
| 1863 | const first = focusable[0]; |
| 1864 | const last = focusable[focusable.length - 1]; |
| 1865 | if (event.shiftKey && document.activeElement === first) { |
| 1866 | event.preventDefault(); |
| 1867 | last.focus(); |
| 1868 | } else if (!event.shiftKey && document.activeElement === last) { |
| 1869 | event.preventDefault(); |
| 1870 | first.focus(); |
| 1871 | } |
| 1872 | } |
| 1873 | |
| 1874 | document.addEventListener("keydown", handleKeyDown); |
| 1875 | return () => document.removeEventListener("keydown", handleKeyDown); |
| 1876 | }, [onClose]); |
| 1877 | |
| 1878 | if (typeof document === "undefined") return null; |
| 1879 | |
| 1880 | return createPortal( |
| 1881 | <div className="readiness-popup-backdrop" onMouseDown={(event) => { |
| 1882 | if (event.target === event.currentTarget) onClose(); |
| 1883 | }}> |
| 1884 | <section |
| 1885 | ref={dialogRef} |
| 1886 | className="readiness-popup" |
| 1887 | role="dialog" |
| 1888 | aria-modal="true" |
| 1889 | aria-labelledby="research-readiness-title" |
| 1890 | tabIndex={-1} |
| 1891 | > |
| 1892 | <header className="readiness-popup-header"> |
| 1893 | <div> |
| 1894 | <p className="eyebrow">Research readiness</p> |
| 1895 | <h2 id="research-readiness-title">{companyName}</h2> |
| 1896 | </div> |
| 1897 | <button data-readiness-close type="button" className="icon-button" aria-label="Close research readiness" onClick={onClose}> |
| 1898 | <X size={20} aria-hidden="true" /> |
| 1899 | </button> |
| 1900 | </header> |
| 1901 | <div className="readiness-popup-body"> |
| 1902 | {loading ? <Skeleton rows={5} /> : null} |
| 1903 | {error ? <p className="readiness-popup-error" role="alert">{error}</p> : null} |
| 1904 | {readiness ? <> |
| 1905 | <div className="readiness-summary" aria-label="Research data completeness"> |
| 1906 | <div><span>Overall status</span><strong>{readiness.overallStatus.replaceAll("_", " ")}</strong></div> |
| 1907 | <div><span>Completeness</span><strong>{readiness.overallCompletenessPct}%</strong></div> |
| 1908 | <div><span>Critical completeness</span><strong>{readiness.criticalCompletenessPct}%</strong></div> |
| 1909 | <div><span>Data confidence</span><strong>{readiness.confidence} · {readiness.confidencePct}%</strong></div> |
| 1910 | </div> |
| 1911 | <div className="readiness-primary-actions"> |
| 1912 | {findable.length ? <Button |
| 1913 | onClick={() => onFindData(findable.map((item) => item.requirementId))} |
| 1914 | disabled={ensuringRequirementIds.length > 0} |
| 1915 | >{ensuringRequirementIds.length ? "Finding targeted data…" : "Find required data"}</Button> : null} |
| 1916 | <div className="readiness-analysis-actions"> |
| 1917 | {readiness.analysisEligibility?.fullAnalysisAllowed ? ( |
| 1918 | <Button onClick={() => onRunAnalysis(false)} disabled={analysisLoading || ensuringRequirementIds.length > 0}> |
| 1919 | {analysisLoading ? "Running deterministic analysis…" : "Run Analysis"} |
| 1920 | </Button> |
| 1921 | ) : readiness.analysisEligibility?.partialAnalysisAllowed ? ( |
| 1922 | <Button variant="secondary" onClick={() => onRunAnalysis(true)} disabled={analysisLoading || ensuringRequirementIds.length > 0}> |
| 1923 | {analysisLoading ? "Running deterministic analysis…" : "Run Partial Analysis"} |
| 1924 | </Button> |
| 1925 | ) : ( |
| 1926 | <p className="readiness-refresh-state">Analysis needs more critical data. Use Find Data for the listed requirements.</p> |
| 1927 | )} |
| 1928 | </div> |
| 1929 | </div> |
| 1930 | {analysisError ? <p className="readiness-popup-error" role="alert">{analysisError}</p> : null} |
| 1931 | {analysis ? <StockRuleEngineBreakdown analysis={analysis} /> : null} |
| 1932 | {readiness.refreshState?.executedCapabilities.length ? <p className="readiness-refresh-state" role="status"> |
| 1933 | Targeted capabilities: {readiness.refreshState.executedCapabilities.join(", ").replaceAll("_", " ")} |
| 1934 | </p> : null} |
| 1935 | <div className="readiness-requirements"> |
| 1936 | {readiness.requirements.map((requirement) => ( |
| 1937 | <ResearchReadinessRow |
| 1938 | key={requirement.requirementId} |
| 1939 | requirement={requirement} |
| 1940 | ensuring={ensuringRequirementIds.includes(requirement.requirementId)} |
| 1941 | onFindData={() => onFindData([requirement.requirementId])} |
| 1942 | /> |
| 1943 | ))} |
| 1944 | </div> |
| 1945 | </> : null} |
| 1946 | </div> |
| 1947 | </section> |
| 1948 | </div>, |
| 1949 | document.body |
| 1950 | ); |
| 1951 | } |
| 1952 | |
| 1953 | function ResearchReadinessRow({ |
| 1954 | requirement, |
| 1955 | ensuring, |
| 1956 | onFindData |
| 1957 | }: { |
| 1958 | requirement: ResearchReadinessRequirement; |
| 1959 | ensuring: boolean; |
| 1960 | onFindData: () => void; |
| 1961 | }) { |
| 1962 | const tone = readinessStatusTone(requirement.status); |
| 1963 | return <article className={`readiness-requirement readiness-requirement-${tone}`}> |
| 1964 | <div className="readiness-requirement-heading"> |
| 1965 | <div> |
| 1966 | <strong>{requirement.area.replaceAll("_", " ")}</strong> |
| 1967 | <small>{requirement.requirementId.replaceAll("_", " ")} · {requirement.importance}</small> |
| 1968 | </div> |
| 1969 | <span className={`readiness-status readiness-status-${tone}`}>{requirement.status.replaceAll("_", " ")}</span> |
| 1970 | </div> |
| 1971 | <p>{requirement.asOf ? `As of ${new Date(requirement.asOf).toLocaleString()}` : "No eligible as-of date"}</p> |
| 1972 | <p>{requirement.sourceProvider ? <>Source: {requirement.sourceProvider.replaceAll("_", " ")}{requirement.sourceUrl ? <> · <a href={requirement.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a></> : null}</> : "Source unavailable"}</p> |
| 1973 | {requirement.applicabilityReason ? <p>Applicability: {requirement.applicability?.replaceAll("_", " ")} · {requirement.applicabilityReason.replaceAll("_", " ")}{requirement.businessClassification ? ` (${requirement.businessClassification})` : ""}</p> : null} |
| 1974 | {requirement.status === "NOT_APPLICABLE" ? <p>Excluded from completeness. No score assigned.</p> : null} |
| 1975 | {requirement.requirementId === "CURRENT_NEWS" && requirement.acquisitionObservation?.history?.some((scan) => scan.outcome === "SUCCESS_EMPTY") ? <p>The latest successful provider scan found no qualifying current events. No news score was inferred.</p> : null} |
| 1976 | {requirement.acquisitionObservation && requirement.acquisitionObservation.outcome && requirement.acquisitionObservation.provider ? <p>Last acquisition: {requirement.acquisitionObservation.outcome.replaceAll("_", " ")} · {requirement.acquisitionObservation.provider.replaceAll("_", " ")}{requirement.acquisitionObservation.failure_reason ? `: ${requirement.acquisitionObservation.failure_reason}` : ""}</p> : null} |
| 1977 | {requirement.missingInputIds.length ? <p>Missing inputs: {requirement.missingInputIds.map((id) => id.replaceAll("_", " ")).join(", ")}</p> : null} |
| 1978 | {requirement.concreteRequirements?.some((input) => input.applicability === "NOT_APPLICABLE") ? <p>Not applicable: {requirement.concreteRequirements.filter((input) => input.applicability === "NOT_APPLICABLE").map((input) => input.inputId.replaceAll("_", " ")).join(", ")}</p> : null} |
| 1979 | {requirement.missingReason ? <p className="readiness-reason">{requirement.missingReason.replaceAll("_", " ")}</p> : null} |
| 1980 | {requirement.conflictReason ? <p className="readiness-reason">{requirement.conflictReason.replaceAll("_", " ")}</p> : null} |
| 1981 | <div className="readiness-actions"> |
| 1982 | {requirement.supportedActions.includes("FIND_DATA") ? <Button onClick={onFindData} disabled={ensuring}>{ensuring ? "Finding…" : "Find Data"}</Button> : null} |
| 1983 | {requirement.supportedActions.includes("UPLOAD_EVIDENCE") ? <Button variant="secondary" disabled title="Evidence upload arrives in a later iteration">Upload Evidence</Button> : null} |
| 1984 | </div> |
| 1985 | </article>; |
| 1986 | } |
| 1987 | |
| 1988 | function StockRuleEngineBreakdown({ analysis }: { analysis: StockRuleEngineAnalysis }) { |
| 1989 | const tone = analysis.riskOverrides.length |
| 1990 | ? "danger" |
| 1991 | : analysis.overallScore != null && analysis.overallScore >= 65 |
| 1992 | ? "ready" |
| 1993 | : analysis.overallScore != null && analysis.overallScore < 50 |
| 1994 | ? "danger" |
| 1995 | : "neutral"; |
| 1996 | return <section className={`rule-engine-result rule-engine-result-${tone}`} aria-label="Deterministic stock analysis"> |
| 1997 | <header> |
| 1998 | <div> |
| 1999 | <p className="eyebrow">{analysis.ruleEngineVersion.replaceAll("_", " ")}</p> |
| 2000 | <h3>{analysis.overallScore == null ? "Insufficient data" : `${analysis.overallScore.toFixed(2)}/100`}</h3> |
| 2001 | </div> |
| 2002 | <span className={`readiness-status readiness-status-${tone}`}>{analysis.decisionSignal.replaceAll("_", " ")}</span> |
| 2003 | </header> |
| 2004 | <div className="rule-engine-score-grid"> |
| 2005 | <div><span>Quality</span><strong>{scoreText(analysis.qualityScore)}</strong></div> |
| 2006 | <div><span>Opportunity</span><strong>{scoreText(analysis.opportunityScore)}</strong></div> |
| 2007 | <div><span>Risk</span><strong>{scoreText(analysis.riskScore)}</strong></div> |
| 2008 | <div><span>Confidence</span><strong>{analysis.confidence} · {analysis.confidenceScore.toFixed(2)}%</strong></div> |
| 2009 | </div> |
| 2010 | <p className="rule-engine-meta"> |
| 2011 | Calculated {new Date(analysis.calculatedAt).toLocaleString()} · input fingerprint {analysis.inputFingerprint.slice(0, 12)} · {analysis.cacheHit ? "cached exact-input result" : "new calculation"} |
| 2012 | </p> |
| 2013 | {analysis.riskOverrides.length ? <div className="rule-engine-overrides" role="alert"> |
| 2014 | <strong>Risk override</strong> |
| 2015 | {analysis.riskOverrides.map((override) => <p key={override.code}>{override.severity}: {override.code.replaceAll("_", " ")} · {override.effect.replaceAll("_", " ")}</p>)} |
| 2016 | </div> : null} |
| 2017 | <div className="rule-engine-area-table" role="table" aria-label="Rule Engine area score breakdown"> |
| 2018 | <div className="rule-engine-area-row rule-engine-area-header" role="row"> |
| 2019 | <span>Area</span><span>Weight</span><span>Score</span><span>Contribution</span><span>Status</span> |
| 2020 | </div> |
| 2021 | {analysis.areaScores.map((area) => <details key={area.area} className="rule-engine-area-row"> |
| 2022 | <summary> |
| 2023 | <span>{area.area.replaceAll("_", " ")}</span> |
| 2024 | <span>{area.weight}%</span> |
| 2025 | <span>{scoreText(area.rawScore)}</span> |
| 2026 | <span>{area.rawScore == null ? "—" : area.weightedContribution.toFixed(2)}</span> |
| 2027 | <span>{area.status.replaceAll("_", " ")}</span> |
| 2028 | </summary> |
| 2029 | <div className="rule-engine-area-detail"> |
| 2030 | {area.metrics.length ? <div className="rule-engine-metrics"> |
| 2031 | {area.metrics.map((metric, index) => <article key={`${metric.rule}-${index}`}> |
| 2032 | <strong>{metric.metric.replaceAll("_", " ")} · {metric.score.toFixed(2)}/100</strong> |
| 2033 | <p>{formatRuleMetricValue(metric.value, metric.unit)} · rule {metric.rule} · applied weight {metric.appliedWeightPct.toFixed(2)}%</p> |
| 2034 | <p>Source: {metric.source.replaceAll("_", " ")}{metric.asOf ? ` · as of ${new Date(metric.asOf).toLocaleDateString()}` : ""}{metric.sourceUrl ? <> · <a href={metric.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a></> : null}</p> |
| 2035 | </article>)} |
| 2036 | </div> : <p>No scoreable metric is available for this area.</p>} |
| 2037 | {area.sourceReferences.length ? <p><strong>Area evidence sources:</strong>{" "}{area.sourceReferences.map((source, index) => <span key={`${source.sourceUrl}-${index}`}>{index ? " · " : ""}<a href={source.sourceUrl} target="_blank" rel="noreferrer">{source.sourceProvider?.replaceAll("_", " ") ?? "Source"} ↗</a>{source.asOf ? ` (${new Date(source.asOf).toLocaleDateString()})` : ""}</span>)}</p> : null} |
| 2038 | {area.positiveFactors.length ? <p><strong>Positive:</strong> {area.positiveFactors.join(" · ")}</p> : null} |
| 2039 | {area.negativeFactors.length ? <p><strong>Negative:</strong> {area.negativeFactors.join(" · ")}</p> : null} |
| 2040 | {area.missingInputs.length ? <p><strong>Missing:</strong> {area.missingInputs.join(", ").replaceAll("_", " ")}</p> : null} |
| 2041 | </div> |
| 2042 | </details>)} |
| 2043 | </div> |
| 2044 | </section>; |
| 2045 | } |
| 2046 | |
| 2047 | function scoreText(value?: number | null): string { |
| 2048 | return value == null ? "—" : value.toFixed(2); |
| 2049 | } |
| 2050 | |
| 2051 | function formatRuleMetricValue(value: unknown, unit?: string | null): string { |
| 2052 | const rendered = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value ?? "—"); |
| 2053 | return unit ? `${rendered} ${unit.replaceAll("_", " ")}` : rendered; |
| 2054 | } |
| 2055 | |
| 2056 | function readinessStatusTone(status: ResearchReadinessRequirement["status"]): "ready" | "warning" | "danger" | "neutral" { |
| 2057 | if (status === "READY_FRESH") return "ready"; |
| 2058 | if (["READY_STALE", "PARTIAL", "REFRESHING"].includes(status)) return "warning"; |
| 2059 | if (status === "UNSUPPORTED" || status === "NOT_APPLICABLE") return "neutral"; |
| 2060 | return "danger"; |
| 2061 | } |
| 2062 | |
| 2063 | function filingSourceLabel(sourceName: string): string { |
| 2064 | return sourceName.trim().split(/\s+/)[0] || "Official"; |
| 2065 | } |
| 2066 | |
| 2067 | function AuthPasswordInput({ name, placeholder }: { name: string; placeholder: string }) { |
| 2068 | const [visible, setVisible] = useState(false); |
| 2069 | return <span className="password-input"><input name={name} type={visible ? "text" : "password"} required minLength={12} placeholder={placeholder} /><button className="password-toggle" type="button" aria-label={visible ? "Hide password" : "Show password"} onClick={() => setVisible(!visible)}>{visible ? <EyeOff size={18} aria-hidden="true" /> : <Eye size={18} aria-hidden="true" />}</button></span>; |
| 2070 | } |
| 2071 | |
| 2072 | function SignInView({ |
| 2073 | error, |
| 2074 | onLogin, |
| 2075 | onEmailLogin |
| 2076 | }: { |
| 2077 | error: ApiFailure | null; |
| 2078 | onLogin: (userKey: "user-a" | "user-b") => void; |
| 2079 | onEmailLogin: (email: string, password: string) => void; |
| 2080 | }) { |
| 2081 | const [mode, setMode] = useState<"LOGIN" | "REGISTER" | "VERIFY" | "FORGOT" | "RESET" | "RESEND">("LOGIN"); |
| 2082 | const [message, setMessage] = useState<string | null>(null); |
| 2083 | const [verificationEmail, setVerificationEmail] = useState(""); |
| 2084 | useEffect(() => { const token = new URLSearchParams(window.location.search).get("token"); if (window.location.pathname === "/verify-email" && token) { queueMicrotask(() => { setMode("VERIFY"); setMessage("Verifying your email..."); authApi.verifyEmail(token).then(() => setMessage("Email verified successfully. You can now sign in.")).catch(() => setMessage("Verification link is invalid or expired.")); }); } }, []); |
| 2085 | async function submit(form: FormData) { |
| 2086 | const email = String(form.get("email") || ""), password = String(form.get("password") || ""); |
| 2087 | if (mode === "LOGIN") return onEmailLogin(email, password); |
| 2088 | if (mode === "VERIFY") { await authApi.verifyEmail(String(form.get("token") || "")); setMessage("Email verified. You can now sign in."); setMode("LOGIN"); return; } |
| 2089 | if (mode === "FORGOT") { const result = await authApi.requestPasswordReset(email); setMessage(result.message); setMode("RESET"); return; } |
| 2090 | if (mode === "RESET") { if (password !== String(form.get("confirmPassword") || "")) { setMessage("Passwords do not match."); return; } await authApi.confirmPasswordReset(String(form.get("token") || ""), password); setMessage("Password reset. You can now sign in."); setMode("LOGIN"); return; } |
| 2091 | if (mode === "RESEND") { await authApi.resendVerification(email); setMessage("If this account is awaiting verification, a new verification email has been sent."); return; } |
| 2092 | if (password !== String(form.get("confirmPassword") || "")) { setMessage("Passwords do not match."); return; } |
| 2093 | await authApi.register({ email, password, firstName: String(form.get("firstName") || ""), lastName: String(form.get("lastName") || "") }); |
| 2094 | setVerificationEmail(email); setMessage("We sent a verification link to your email address."); setMode("VERIFY"); |
| 2095 | } |
| 2096 | return ( |
| 2097 | <main className="signin-shell"> |
| 2098 | <section className="signin-panel"> |
| 2099 | <div className="brand-lockup"> |
| 2100 | <div className="brand-mark">AI</div> |
| 2101 | <div> |
| 2102 | <strong>AI Investment</strong> |
| 2103 | <span>Secure workspace</span> |
| 2104 | </div> |
| 2105 | </div> |
| 2106 | <h1>{mode === "LOGIN" ? "Sign in" : mode === "REGISTER" ? "Create account" : mode === "VERIFY" ? "Verify email" : mode === "FORGOT" ? "Forgot password" : mode === "RESEND" ? "Resend verification email" : "Reset password"}</h1> |
| 2107 | <p>{mode === "LOGIN" ? "Use your verified account." : "Complete the account verification step to continue."}</p> |
| 2108 | {error ? <ErrorState message={error.message} correlationId={error.correlationId} /> : null} |
| 2109 | {message ? <p role="status">{message}</p> : null} |
| 2110 | <form className="signin-actions" action={(form) => void submit(form).catch((err) => setMessage(getApiFailure(err).message))}> |
| 2111 | {mode === "VERIFY" ? (frontendConfig.authDevLoginEnabled ? <input name="token" required placeholder="Verification token" /> : <p>Check your email and open the verification link.</p>) : mode === "RESET" ? <><input name="token" required placeholder="Reset token" /><AuthPasswordInput name="password" placeholder="New password" /><AuthPasswordInput name="confirmPassword" placeholder="Confirm new password" /></> : mode === "FORGOT" || mode === "RESEND" ? <input name="email" type="email" required placeholder="Email" /> : <><input name="email" type="email" required placeholder="Email" />{mode === "REGISTER" ? <><input name="firstName" required placeholder="First name" /><input name="lastName" required placeholder="Last name" /></> : null}<AuthPasswordInput name="password" placeholder="Password (minimum 12 characters)" />{mode === "REGISTER" ? <AuthPasswordInput name="confirmPassword" placeholder="Confirm password" /> : null}</>} |
| 2112 | <Button type="submit">{mode === "LOGIN" ? "Sign in" : mode === "REGISTER" ? "Register" : mode === "RESEND" ? "Resend verification email" : "Verify email"}</Button> |
| 2113 | </form> |
| 2114 | <p><button type="button" onClick={() => setMode(mode === "LOGIN" ? "REGISTER" : "LOGIN")}>{mode === "LOGIN" ? "Create an account" : "Back to sign in"}</button></p> |
| 2115 | {mode === "VERIFY" ? <p><button type="button" onClick={() => authApi.resendVerification(verificationEmail).then((result) => setMessage(result.message))}>Resend verification email</button></p> : null} |
| 2116 | {mode === "LOGIN" ? <><p><button type="button" onClick={() => setMode("FORGOT")}>Forgot password?</button></p><p><button type="button" onClick={() => setMode("RESEND")}>Resend verification email</button></p></> : null} |
| 2117 | {frontendConfig.authDevLoginEnabled ? <div className="signin-actions"> |
| 2118 | <Button onClick={() => onLogin("user-a")}>Sign in as User A</Button> |
| 2119 | <Button variant="secondary" onClick={() => onLogin("user-b")}>Sign in as User B</Button> |
| 2120 | </div> : null} |
| 2121 | </section> |
| 2122 | </main> |
| 2123 | ); |
| 2124 | } |
| 2125 | |
| 2126 | function PortfolioSelector({ |
| 2127 | portfolios, |
| 2128 | selectedPortfolioId, |
| 2129 | onChange |
| 2130 | }: { |
| 2131 | portfolios: PortfolioListItem[]; |
| 2132 | selectedPortfolioId: string; |
| 2133 | onChange: (value: string) => void; |
| 2134 | }) { |
| 2135 | if (portfolios.length === 0) { |
| 2136 | return null; |
| 2137 | } |
| 2138 | |
| 2139 | return ( |
| 2140 | <label className="portfolio-select"> |
| 2141 | <span>Portfolio</span> |
| 2142 | <select value={selectedPortfolioId} onChange={(event) => onChange(event.target.value)}> |
| 2143 | {portfolios.map((portfolio) => ( |
| 2144 | <option value={portfolio.portfolioId} key={portfolio.portfolioId}> |
| 2145 | {portfolio.name} |
| 2146 | </option> |
| 2147 | ))} |
| 2148 | </select> |
| 2149 | </label> |
| 2150 | ); |
| 2151 | } |
| 2152 | |
| 2153 | function ResearchContextSelector({ |
| 2154 | portfolios, |
| 2155 | watchlists, |
| 2156 | context, |
| 2157 | onChange, |
| 2158 | }: { |
| 2159 | portfolios: PortfolioListItem[]; |
| 2160 | watchlists: ResearchWatchlist[]; |
| 2161 | context: ResearchContext; |
| 2162 | onChange: (value: string) => void; |
| 2163 | }) { |
| 2164 | const value = context.kind === "PORTFOLIO" |
| 2165 | ? `portfolio:${context.portfolioId}` |
| 2166 | : context.kind === "WATCHLIST" |
| 2167 | ? `watchlist:${context.watchlistId}` |
| 2168 | : `pending:${context.region}`; |
| 2169 | return ( |
| 2170 | <label className="portfolio-select research-context-select"> |
| 2171 | <span>Research context</span> |
| 2172 | <select value={value} onChange={(event) => onChange(event.target.value)}> |
| 2173 | {context.kind === "WATCHLIST_PENDING" ? ( |
| 2174 | <option value={value}>Opening {context.name}…</option> |
| 2175 | ) : null} |
| 2176 | <optgroup label="Portfolios"> |
| 2177 | {portfolios.map((portfolio) => ( |
| 2178 | <option value={`portfolio:${portfolio.portfolioId}`} key={`portfolio:${portfolio.portfolioId}`}> |
| 2179 | {portfolio.name} |
| 2180 | </option> |
| 2181 | ))} |
| 2182 | </optgroup> |
| 2183 | <optgroup label="Watchlists"> |
| 2184 | {watchlists.map((watchlist) => ( |
| 2185 | <option value={`watchlist:${watchlist.watchlistId}`} key={`watchlist:${watchlist.watchlistId}`}> |
| 2186 | {watchlist.name} |
| 2187 | </option> |
| 2188 | ))} |
| 2189 | </optgroup> |
| 2190 | </select> |
| 2191 | </label> |
| 2192 | ); |
| 2193 | } |
| 2194 | |
| 2195 | function LoadingView() { |
| 2196 | return ( |
| 2197 | <div className="loading-grid"> |
| 2198 | <Skeleton rows={4} /> |
| 2199 | <Skeleton rows={4} /> |
| 2200 | <Skeleton rows={8} /> |
| 2201 | </div> |
| 2202 | ); |
| 2203 | } |
| 2204 | |
| 2205 | function PortfolioTabs({ |
| 2206 | portfolios, |
| 2207 | selected, |
| 2208 | onSelect |
| 2209 | }: { |
| 2210 | portfolios: PortfolioListItem[]; |
| 2211 | selected: "ALL" | string; |
| 2212 | onSelect: (value: "ALL" | string) => void; |
| 2213 | }) { |
| 2214 | return ( |
| 2215 | <nav className="portfolio-tabs" aria-label="Portfolio views"> |
| 2216 | <button className={selected === "ALL" ? "active" : ""} onClick={() => onSelect("ALL")} type="button">All</button> |
| 2217 | {portfolios.map((portfolio) => ( |
| 2218 | <button |
| 2219 | className={selected === portfolio.portfolioId ? "active" : ""} |
| 2220 | onClick={() => onSelect(portfolio.portfolioId)} |
| 2221 | type="button" |
| 2222 | key={portfolio.portfolioId} |
| 2223 | > |
| 2224 | {portfolio.name} |
| 2225 | </button> |
| 2226 | ))} |
| 2227 | </nav> |
| 2228 | ); |
| 2229 | } |
| 2230 | |
| 2231 | function MultiPortfolioDashboard({ |
| 2232 | dashboard, |
| 2233 | performance, |
| 2234 | performanceRegion, |
| 2235 | performanceSector, |
| 2236 | performancePeriod, |
| 2237 | sectorOptions, |
| 2238 | sectorOptionsLoading, |
| 2239 | onPerformanceRegion, |
| 2240 | onPerformanceSector, |
| 2241 | onPerformancePeriod, |
| 2242 | onCreate, |
| 2243 | creating, |
| 2244 | selectedResearchInstrumentId, |
| 2245 | onOpenResearch, |
| 2246 | onAddWatchlist, |
| 2247 | savedInstrumentIds, |
| 2248 | watchlistBusy, |
| 2249 | researchOnly = false, |
| 2250 | }: { |
| 2251 | onAddWatchlist: (selection: MarketIntelligenceSelection) => void; |
| 2252 | savedInstrumentIds: string[]; |
| 2253 | watchlistBusy: boolean; |
| 2254 | researchOnly?: boolean; |
| 2255 | dashboard: PortfolioDashboard | null; |
| 2256 | performance: SectorPerformance | null; |
| 2257 | performanceRegion: SectorPerformance["region"]; |
| 2258 | performanceSector: string; |
| 2259 | performancePeriod: SectorPerformance["period"]; |
| 2260 | sectorOptions: MarketUniverseSector[]; |
| 2261 | sectorOptionsLoading: boolean; |
| 2262 | onPerformanceRegion: (value: SectorPerformance["region"]) => void; |
| 2263 | onPerformanceSector: (value: string) => void; |
| 2264 | onPerformancePeriod: (value: SectorPerformance["period"]) => void; |
| 2265 | onCreate: () => void; |
| 2266 | creating: boolean; |
| 2267 | selectedResearchInstrumentId: string; |
| 2268 | onOpenResearch: (selection: MarketIntelligenceSelection) => void; |
| 2269 | }) { |
| 2270 | if (!dashboard && !researchOnly) return null; |
| 2271 | return ( |
| 2272 | <div className="dashboard-grid"> |
| 2273 | {!researchOnly && dashboard ? <> |
| 2274 | <Card className="wide-panel"> |
| 2275 | <div className="panel-header"><div><p className="eyebrow">My portfolios</p><h2>Total portfolio value</h2></div></div> |
| 2276 | <div className="currency-total-grid"> |
| 2277 | {Object.entries(dashboard.currencyTotals).map(([currency, amount]) => ( |
| 2278 | <MetricCard label={currency} value={formatMoney(amount, currency)} key={currency} /> |
| 2279 | ))} |
| 2280 | </div> |
| 2281 | {dashboard.incompleteValuationPortfolioIds.length > 0 ? ( |
| 2282 | <p className="broker-note">Some portfolios have incomplete valuation data and are not combined with another currency.</p> |
| 2283 | ) : null} |
| 2284 | </Card> |
| 2285 | <section className="portfolio-card-grid" aria-label="Persisted portfolios"> |
| 2286 | {dashboard.portfolios.map((portfolio) => ( |
| 2287 | <Card as="article" key={portfolio.portfolioId}> |
| 2288 | <Badge tone={portfolio.provider ? "positive" : "neutral"}>{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual"}</Badge> |
| 2289 | <h2>{portfolio.name}</h2> |
| 2290 | <strong>{formatBackendMoney(portfolio.totalMarketValue)}</strong> |
| 2291 | <p>{portfolio.acquisitionSource === "MANUAL_CSV_IMPORT" |
| 2292 | ? `Imported snapshot: ${portfolio.lastImportedAt ? new Date(portfolio.lastImportedAt).toLocaleString() : "Not available"}` |
| 2293 | : `Broker holdings synced: ${portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : portfolio.brokerConnectionId ? "Imported previously; sync time unknown" : "Not applicable"}`}</p> |
| 2294 | <p>Market price updated independently in the holdings view.</p> |
| 2295 | </Card> |
| 2296 | ))} |
| 2297 | </section> |
| 2298 | {dashboard.portfolios.length === 0 ? <Card className="wide-panel"><EmptyState title="No portfolios" message="Connect a broker or create a portfolio to begin." /><Button onClick={onCreate} disabled={creating}>{creating ? "Creating..." : "Create portfolio"}</Button></Card> : null} |
| 2299 | </> : null} |
| 2300 | <Card className="wide-panel"> |
| 2301 | <div className="panel-header"><div><p className="eyebrow">Market intelligence</p><h2>Sector Performance</h2><p>Top gainers from durable market-price observations.</p></div></div> |
| 2302 | <div className="form-grid"> |
| 2303 | <Field label="Region"><select aria-label="Sector performance region" value={performanceRegion} onChange={(event) => onPerformanceRegion(event.target.value as SectorPerformance["region"])}>{["USA", "EUROPE", "INDIA"].map((value) => <option key={value}>{value}</option>)}</select></Field> |
| 2304 | <Field label="Sector"><select aria-label="Sector performance sector" value={performanceSector} disabled={sectorOptionsLoading || sectorOptions.length === 0} onChange={(event) => onPerformanceSector(event.target.value)}>{sectorOptions.length ? sectorOptions.map((value) => <option key={value.name} value={value.name}>{value.name} ({value.instrumentCount})</option>) : <option value="">{sectorOptionsLoading ? "Loading durable sectors…" : "No durable sectors"}</option>}</select></Field> |
| 2305 | <Field label="Period"><select aria-label="Sector performance period" value={performancePeriod} onChange={(event) => onPerformancePeriod(event.target.value as SectorPerformance["period"])}>{["DAY", "WEEK", "MONTH", "YEAR"].map((value) => <option key={value}>{value}</option>)}</select></Field> |
| 2306 | </div> |
| 2307 | {sectorOptionsLoading ? <p className="broker-note">Loading durable sector universe…</p> |
| 2308 | : sectorOptions.length === 0 ? <p className="broker-note">No durable sector-classified universe is available for this region.</p> |
| 2309 | : performance ? <div className="stack-gap"> |
| 2310 | <div><h3>Top 5 Performers</h3>{performance.bestPerformers.length ? <section className="portfolio-card-grid" aria-label="Sector performance top performers">{performance.bestPerformers.map((stock) => <MarketPerformanceRow stock={stock} key={`best-${stock.globalInstrumentId}`} onOpenResearch={(value) => onOpenResearch(marketIntelligenceSelection(performance, value))} selected={selectedResearchInstrumentId === stock.globalInstrumentId} watchlistName={regionalWatchlistName(performance.region)} saved={savedInstrumentIds.includes(stock.globalInstrumentId)} busy={watchlistBusy} onAddWatchlist={() => onAddWatchlist(marketIntelligenceSelection(performance, stock))} />)}</section> : <p className="broker-note">Insufficient historical data for top performers.</p>}</div> |
| 2311 | <div><h3>Worst 5 Performers</h3>{performance.worstPerformers.length ? <section className="portfolio-card-grid" aria-label="Sector performance worst performers">{performance.worstPerformers.map((stock) => <MarketPerformanceRow stock={stock} key={`worst-${stock.globalInstrumentId}`} onOpenResearch={(value) => onOpenResearch(marketIntelligenceSelection(performance, value))} selected={selectedResearchInstrumentId === stock.globalInstrumentId} watchlistName={regionalWatchlistName(performance.region)} saved={savedInstrumentIds.includes(stock.globalInstrumentId)} busy={watchlistBusy} onAddWatchlist={() => onAddWatchlist(marketIntelligenceSelection(performance, stock))} />)}</section> : <p className="broker-note">Insufficient historical data for worst performers.</p>}</div> |
| 2312 | </div> : <p className="broker-note">Loading sector performance…</p>} |
| 2313 | </Card> |
| 2314 | </div> |
| 2315 | ); |
| 2316 | } |
| 2317 | |
| 2318 | function MarketPerformanceRow({ |
| 2319 | stock, |
| 2320 | selected, |
| 2321 | onOpenResearch, |
| 2322 | onAddWatchlist, |
| 2323 | watchlistName, |
| 2324 | saved, |
| 2325 | busy, |
| 2326 | }: { |
| 2327 | onAddWatchlist: () => void; |
| 2328 | watchlistName: string; |
| 2329 | saved: boolean; |
| 2330 | busy: boolean; |
| 2331 | stock: SectorPerformanceStock; |
| 2332 | selected: boolean; |
| 2333 | onOpenResearch: (stock: SectorPerformanceStock) => void; |
| 2334 | }) { |
| 2335 | const tone = performanceRowTone(stock.performancePct); |
| 2336 | const signedPerformance = formatSignedPerformancePct(stock.performancePct); |
| 2337 | return ( |
| 2338 | <article className="stack-gap" data-global-instrument-id={stock.globalInstrumentId}> |
| 2339 | <button |
| 2340 | className={`market-performance-row market-performance-row-${tone}`} |
| 2341 | type="button" |
| 2342 | data-performance-direction={tone} |
| 2343 | aria-current={selected ? "true" : undefined} |
| 2344 | aria-label={`${stock.companyName}, ${signedPerformance}, ${performanceDirectionLabel(stock)}`} |
| 2345 | onClick={() => onOpenResearch(stock)} |
| 2346 | > |
| 2347 | <strong>{stock.companyName}</strong> |
| 2348 | <span className="market-performance-identity">{stock.ticker} · {stock.exchange}</span> |
| 2349 | <span className="market-performance-return">{signedPerformance}</span> |
| 2350 | </button> |
| 2351 | <Button variant="secondary" disabled={saved || busy || !stock.globalInstrumentId?.trim()} onClick={onAddWatchlist}> |
| 2352 | {saved ? `Saved in ${watchlistName}` : `Add to ${watchlistName}`} |
| 2353 | </Button> |
| 2354 | </article> |
| 2355 | ); |
| 2356 | } |
| 2357 | |
| 2358 | function PortfolioCreatePanel({ |
| 2359 | onCreate, |
| 2360 | creating, |
| 2361 | newPortfolioName, |
| 2362 | newPortfolioCurrency, |
| 2363 | setNewPortfolioName, |
| 2364 | setNewPortfolioCurrency |
| 2365 | }: { |
| 2366 | onCreate: () => void; |
| 2367 | creating: boolean; |
| 2368 | newPortfolioName: string; |
| 2369 | newPortfolioCurrency: string; |
| 2370 | setNewPortfolioName: (value: string) => void; |
| 2371 | setNewPortfolioCurrency: (value: string) => void; |
| 2372 | }) { |
| 2373 | return ( |
| 2374 | <Card className="create-panel"> |
| 2375 | <div> |
| 2376 | <h2>Create a portfolio</h2> |
| 2377 | <p>Connect a broker or create a portfolio to get started.</p> |
| 2378 | </div> |
| 2379 | <div className="form-grid"> |
| 2380 | <Field label="Name"> |
| 2381 | <input value={newPortfolioName} onChange={(event) => setNewPortfolioName(event.target.value)} /> |
| 2382 | </Field> |
| 2383 | <Field label="Base currency" hint="ISO 4217 code"> |
| 2384 | <input |
| 2385 | value={newPortfolioCurrency} |
| 2386 | maxLength={3} |
| 2387 | onChange={(event) => setNewPortfolioCurrency(event.target.value.toUpperCase())} |
| 2388 | /> |
| 2389 | </Field> |
| 2390 | </div> |
| 2391 | <Button onClick={onCreate} disabled={creating || newPortfolioName.trim().length === 0}> |
| 2392 | {creating ? "Creating..." : "Create portfolio"} |
| 2393 | </Button> |
| 2394 | </Card> |
| 2395 | ); |
| 2396 | } |
| 2397 | |
| 2398 | function DashboardView({ |
| 2399 | portfolio, |
| 2400 | summary, |
| 2401 | positions, |
| 2402 | onCreate, |
| 2403 | onSync, |
| 2404 | onRefreshPrices, |
| 2405 | creating, |
| 2406 | syncing, |
| 2407 | newPortfolioName, |
| 2408 | newPortfolioCurrency, |
| 2409 | setNewPortfolioName, |
| 2410 | setNewPortfolioCurrency |
| 2411 | }: { |
| 2412 | portfolio?: Portfolio; |
| 2413 | summary: PortfolioSummary | null; |
| 2414 | positions: PortfolioPosition[]; |
| 2415 | onCreate: () => void; |
| 2416 | onSync?: () => void; |
| 2417 | onRefreshPrices?: () => void; |
| 2418 | creating: boolean; |
| 2419 | syncing: boolean; |
| 2420 | newPortfolioName: string; |
| 2421 | newPortfolioCurrency: string; |
| 2422 | setNewPortfolioName: (value: string) => void; |
| 2423 | setNewPortfolioCurrency: (value: string) => void; |
| 2424 | }) { |
| 2425 | if (!portfolio) { |
| 2426 | return ( |
| 2427 | <PortfolioCreatePanel |
| 2428 | onCreate={onCreate} |
| 2429 | creating={creating} |
| 2430 | newPortfolioName={newPortfolioName} |
| 2431 | newPortfolioCurrency={newPortfolioCurrency} |
| 2432 | setNewPortfolioName={setNewPortfolioName} |
| 2433 | setNewPortfolioCurrency={setNewPortfolioCurrency} |
| 2434 | /> |
| 2435 | ); |
| 2436 | } |
| 2437 | |
| 2438 | const pricedPositions = positions.filter((position) => position.unrealizedProfitLossPercent != null); |
| 2439 | const topGainers = [...pricedPositions].sort((a, b) => (b.unrealizedProfitLossPercent ?? 0) - (a.unrealizedProfitLossPercent ?? 0)).slice(0, 3); |
| 2440 | const topLosers = [...pricedPositions].sort((a, b) => (a.unrealizedProfitLossPercent ?? 0) - (b.unrealizedProfitLossPercent ?? 0)).slice(0, 3); |
| 2441 | const sourceLabels = portfolioSourceLabels(positions); |
| 2442 | const liveTotals = onRefreshPrices && positions.every((position) => position.marketValue && position.unrealizedProfitLoss) |
| 2443 | ? positions.reduce((totals, position) => ({ |
| 2444 | marketValue: totals.marketValue + (position.marketValue?.amount ?? 0), |
| 2445 | pnl: totals.pnl + (position.unrealizedProfitLoss?.amount ?? 0), |
| 2446 | cost: totals.cost + position.costBasis.amount, |
| 2447 | }), { marketValue: 0, pnl: 0, cost: 0 }) : null; |
| 2448 | const syncAction = onSync ? ( |
| 2449 | <Button onClick={onSync} disabled={syncing} variant="secondary"> |
| 2450 | <RefreshCw size={16} /> |
| 2451 | {syncing ? "Syncing..." : sourceLabels.syncButton} |
| 2452 | </Button> |
| 2453 | ) : null; |
| 2454 | const refreshAction = onRefreshPrices ? ( |
| 2455 | <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"}</Button> |
| 2456 | ) : null; |
| 2457 | |
| 2458 | return ( |
| 2459 | <div className="dashboard-grid"> |
| 2460 | <section className="metrics-grid" aria-label="Portfolio summary"> |
| 2461 | <MetricCard |
| 2462 | label="Portfolio value" |
| 2463 | value={liveTotals ? formatMoney(liveTotals.marketValue, portfolio.baseCurrency) : formatBackendMoney(summary?.totalMarketValue)} |
| 2464 | meta={sourceLabels.syncMeta} |
| 2465 | /> |
| 2466 | <MetricCard label={sourceLabels.totalProfitLoss} value={liveTotals ? formatMoney(liveTotals.pnl, portfolio.baseCurrency) : formatBackendMoney(summary?.unrealizedProfitLoss)} tone={valueTone(liveTotals?.pnl ?? summary?.unrealizedProfitLoss?.amount)} /> |
| 2467 | <MetricCard label={sourceLabels.returnLabel} value={formatPercent(liveTotals && liveTotals.cost ? liveTotals.pnl / liveTotals.cost * 100 : summary?.unrealizedProfitLossPercent)} tone={valueTone(liveTotals?.pnl ?? summary?.unrealizedProfitLossPercent)} /> |
| 2468 | <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} /> |
| 2469 | <MetricCard label="Holdings" value={String(summary?.positions ?? 0)} /> |
| 2470 | <MetricCard label="Portfolio risk" value="Pending" meta="Risk service not implemented" tone="warning" /> |
| 2471 | </section> |
| 2472 | |
| 2473 | <Card className="wide-panel"> |
| 2474 | <div className="panel-header"> |
| 2475 | <div> |
| 2476 | <h2>{portfolio.name}</h2> |
| 2477 | <p>Last updated: {summary ? sourceLabels.lastUpdated : "not synced"} - Source: {sourceLabels.source}</p> |
| 2478 | </div> |
| 2479 | {syncAction}{refreshAction} |
| 2480 | </div> |
| 2481 | {summary && !onRefreshPrices ? <AllocationCharts summary={summary} /> : positions.length ? <p>Allocation uses refreshed holding values in the Portfolio view.</p> : <EmptyState title="No positions" message="This portfolio currently has no positions." />} |
| 2482 | </Card> |
| 2483 | |
| 2484 | <MovementPanel title="Top gainers" icon={TrendingUp} positions={topGainers} sourceLabels={sourceLabels} /> |
| 2485 | <MovementPanel title="Top losers" icon={TrendingDown} positions={topLosers} sourceLabels={sourceLabels} /> |
| 2486 | |
| 2487 | <Card className="wide-panel"> |
| 2488 | <div className="panel-header"> |
| 2489 | <div> |
| 2490 | <h2>AI opportunities</h2> |
| 2491 | <p>Recommendation logic is not yet available.</p> |
| 2492 | </div> |
| 2493 | <Badge tone="neutral">Future ready</Badge> |
| 2494 | </div> |
| 2495 | <EmptyState title="No AI ratings yet" message="BUY, HOLD, SELL, and opportunity scores will appear after recommendation services are approved and implemented." /> |
| 2496 | </Card> |
| 2497 | </div> |
| 2498 | ); |
| 2499 | } |
| 2500 | |
| 2501 | function MovementPanel({ |
| 2502 | title, |
| 2503 | icon: Icon, |
| 2504 | positions, |
| 2505 | sourceLabels |
| 2506 | }: { |
| 2507 | title: string; |
| 2508 | icon: typeof TrendingUp; |
| 2509 | positions: PortfolioPosition[]; |
| 2510 | sourceLabels: ReturnType<typeof portfolioSourceLabels>; |
| 2511 | }) { |
| 2512 | return ( |
| 2513 | <Card> |
| 2514 | <div className="panel-header compact"> |
| 2515 | <h2>{title}</h2> |
| 2516 | <Icon size={18} aria-hidden="true" /> |
| 2517 | </div> |
| 2518 | {positions.length === 0 ? ( |
| 2519 | <EmptyState title="No synced positions" message={sourceLabels.emptyMessage} /> |
| 2520 | ) : ( |
| 2521 | <div className="movement-list"> |
| 2522 | {positions.map((position) => ( |
| 2523 | <div key={position.positionId}> |
| 2524 | <span>{position.instrument.ticker}</span> |
| 2525 | <strong className={position.unrealizedProfitLossPercent == null ? undefined : position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}> |
| 2526 | {formatPercent(position.unrealizedProfitLossPercent)} |
| 2527 | </strong> |
| 2528 | </div> |
| 2529 | ))} |
| 2530 | </div> |
| 2531 | )} |
| 2532 | </Card> |
| 2533 | ); |
| 2534 | } |
| 2535 | |
| 2536 | function PortfolioView({ |
| 2537 | portfolio, |
| 2538 | summary, |
| 2539 | positions, |
| 2540 | rawPositions, |
| 2541 | history, |
| 2542 | historyRange, |
| 2543 | historyLoading, |
| 2544 | onHistoryRange, |
| 2545 | searchText, |
| 2546 | sortKey, |
| 2547 | onSearch, |
| 2548 | onSort, |
| 2549 | onCreate, |
| 2550 | onSync, |
| 2551 | onRefreshPrices, |
| 2552 | creating, |
| 2553 | syncing, |
| 2554 | newPortfolioName, |
| 2555 | newPortfolioCurrency, |
| 2556 | setNewPortfolioName, |
| 2557 | setNewPortfolioCurrency, |
| 2558 | onUpdateDisplayName, |
| 2559 | portfolioResearch |
| 2560 | }: { |
| 2561 | portfolio?: Portfolio; |
| 2562 | summary: PortfolioSummary | null; |
| 2563 | positions: PortfolioPosition[]; |
| 2564 | rawPositions: PortfolioPosition[]; |
| 2565 | history: PortfolioHistory | null; |
| 2566 | historyRange: PortfolioHistoryRange; |
| 2567 | historyLoading: boolean; |
| 2568 | onHistoryRange: (value: PortfolioHistoryRange) => void; |
| 2569 | searchText: string; |
| 2570 | sortKey: SortKey; |
| 2571 | onSearch: (value: string) => void; |
| 2572 | onSort: (value: SortKey) => void; |
| 2573 | onCreate: () => void; |
| 2574 | onSync?: () => void; |
| 2575 | onRefreshPrices?: () => void; |
| 2576 | creating: boolean; |
| 2577 | syncing: boolean; |
| 2578 | newPortfolioName: string; |
| 2579 | newPortfolioCurrency: string; |
| 2580 | setNewPortfolioName: (value: string) => void; |
| 2581 | setNewPortfolioCurrency: (value: string) => void; |
| 2582 | onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>; |
| 2583 | portfolioResearch: PortfolioResearchSummary | null; |
| 2584 | }) { |
| 2585 | if (!portfolio) { |
| 2586 | return ( |
| 2587 | <PortfolioCreatePanel |
| 2588 | onCreate={onCreate} |
| 2589 | creating={creating} |
| 2590 | newPortfolioName={newPortfolioName} |
| 2591 | newPortfolioCurrency={newPortfolioCurrency} |
| 2592 | setNewPortfolioName={setNewPortfolioName} |
| 2593 | setNewPortfolioCurrency={setNewPortfolioCurrency} |
| 2594 | /> |
| 2595 | ); |
| 2596 | } |
| 2597 | |
| 2598 | const sourceLabels = portfolioSourceLabels(rawPositions); |
| 2599 | const manualMarketTotals = onRefreshPrices && rawPositions.every((position) => position.marketValue && position.unrealizedProfitLoss) |
| 2600 | ? rawPositions.reduce((totals, position) => ({ |
| 2601 | marketValue: totals.marketValue + (position.marketValue?.amount ?? 0), |
| 2602 | costBasis: totals.costBasis + position.costBasis.amount, |
| 2603 | pnl: totals.pnl + (position.unrealizedProfitLoss?.amount ?? 0) |
| 2604 | }), { marketValue: 0, costBasis: 0, pnl: 0 }) : null; |
| 2605 | const displayCurrency = summary?.baseCurrency ?? portfolio.baseCurrency; |
| 2606 | const displayReturn = manualMarketTotals && manualMarketTotals.costBasis !== 0 |
| 2607 | ? manualMarketTotals.pnl / manualMarketTotals.costBasis * 100 |
| 2608 | : summary?.unrealizedProfitLossPercent; |
| 2609 | |
| 2610 | return ( |
| 2611 | <div className="portfolio-layout"> |
| 2612 | <Card className="wide-panel"> |
| 2613 | <div className="panel-header"> |
| 2614 | <div> |
| 2615 | <p className="eyebrow">{portfolio.provider ? brokerDisplayName(portfolio.provider) : "Manual portfolio"}</p> |
| 2616 | <h2>{portfolio.name}</h2> |
| 2617 | {portfolio.brokerConnectionId ? <p>Last successful sync: {portfolio.lastBrokerSyncAt ? new Date(portfolio.lastBrokerSyncAt).toLocaleString() : "Not yet available"}</p> : null} |
| 2618 | </div> |
| 2619 | {onSync ? <Button onClick={onSync} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Syncing..." : "Sync"}</Button> : null} |
| 2620 | {onRefreshPrices ? <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary"><RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"}</Button> : null} |
| 2621 | </div> |
| 2622 | <p>Base currency: {portfolio.baseCurrency}</p> |
| 2623 | </Card> |
| 2624 | <section className="metrics-grid" aria-label="Portfolio totals"> |
| 2625 | <MetricCard label="Securities market value" value={manualMarketTotals ? formatMoney(manualMarketTotals.marketValue, displayCurrency) : formatBackendMoney(summary?.totalMarketValue)} /> |
| 2626 | <MetricCard label="Total portfolio value" value={manualMarketTotals ? formatMoney(manualMarketTotals.marketValue + (summary?.cash.amount ?? 0), displayCurrency) : summary?.totalMarketValue ? formatMoney(summary.totalMarketValue.amount + summary.cash.amount, summary.baseCurrency) : "N/A"} /> |
| 2627 | <MetricCard label={sourceLabels.costBasis} value={manualMarketTotals ? formatMoney(manualMarketTotals.costBasis, displayCurrency) : formatBackendMoney(summary?.totalCostBasis)} /> |
| 2628 | <MetricCard label={sourceLabels.unrealizedProfitLoss} value={manualMarketTotals ? formatMoney(manualMarketTotals.pnl, displayCurrency) : formatBackendMoney(summary?.unrealizedProfitLoss)} tone={valueTone(manualMarketTotals?.pnl ?? summary?.unrealizedProfitLoss?.amount)} /> |
| 2629 | <MetricCard label={sourceLabels.returnLabel} value={formatPercent(displayReturn)} /> |
| 2630 | <MetricCard label="Cash" value={formatBackendMoney(summary?.cash)} /> |
| 2631 | <MetricCard label="Position count" value={String(summary?.positions ?? 0)} /> |
| 2632 | </section> |
| 2633 | |
| 2634 | <PortfolioHistoryPanel |
| 2635 | history={history} |
| 2636 | range={historyRange} |
| 2637 | loading={historyLoading} |
| 2638 | onRange={onHistoryRange} |
| 2639 | /> |
| 2640 | |
| 2641 | <Card className="wide-panel"> |
| 2642 | <div className="panel-header"> |
| 2643 | <div> |
| 2644 | <h2>Holdings</h2> |
| 2645 | <p>Search respects ticker, company, ISIN, exchange, and country.</p> |
| 2646 | <ResearchCoverage research={portfolioResearch} /> |
| 2647 | </div> |
| 2648 | {onSync ? ( |
| 2649 | <Button onClick={onSync} disabled={syncing} variant="secondary"> |
| 2650 | <RefreshCw size={16} /> |
| 2651 | {syncing ? "Syncing..." : sourceLabels.syncButton} |
| 2652 | </Button> |
| 2653 | ) : null} |
| 2654 | {onRefreshPrices ? ( |
| 2655 | <Button onClick={onRefreshPrices} disabled={syncing} variant="secondary"> |
| 2656 | <RefreshCw size={16} />{syncing ? "Refreshing..." : "Refresh Prices"} |
| 2657 | </Button> |
| 2658 | ) : null} |
| 2659 | </div> |
| 2660 | <div className="table-toolbar"> |
| 2661 | <div className="table-search"> |
| 2662 | <Search size={16} aria-hidden="true" /> |
| 2663 | <input |
| 2664 | aria-label="Filter holdings" |
| 2665 | placeholder="Filter holdings" |
| 2666 | value={searchText} |
| 2667 | onChange={(event) => onSearch(event.target.value)} |
| 2668 | /> |
| 2669 | </div> |
| 2670 | <label className="sort-control"> |
| 2671 | <SlidersHorizontal size={16} aria-hidden="true" /> |
| 2672 | <span>Sort</span> |
| 2673 | <select value={sortKey} onChange={(event) => onSort(event.target.value as SortKey)}> |
| 2674 | <option value="marketValue">{sourceLabels.sortMarketValue}</option> |
| 2675 | <option value="profitLoss">{sourceLabels.sortProfitLoss}</option> |
| 2676 | <option value="allocation">Allocation</option> |
| 2677 | <option value="company">Company</option> |
| 2678 | <option value="ticker">Ticker</option> |
| 2679 | </select> |
| 2680 | </label> |
| 2681 | </div> |
| 2682 | {rawPositions.length === 0 ? ( |
| 2683 | <EmptyState |
| 2684 | title="No positions" |
| 2685 | message="This portfolio currently has no positions." |
| 2686 | action={onSync ? <Button onClick={onSync}>{sourceLabels.syncButton}</Button> : undefined} |
| 2687 | /> |
| 2688 | ) : ( |
| 2689 | <HoldingsTable positions={positions} summary={summary} portfolioResearch={portfolioResearch} onUpdateDisplayName={onUpdateDisplayName} /> |
| 2690 | )} |
| 2691 | </Card> |
| 2692 | |
| 2693 | {summary && !onRefreshPrices ? ( |
| 2694 | <Card className="wide-panel"> |
| 2695 | <div className="panel-header"> |
| 2696 | <div> |
| 2697 | <h2>Allocation</h2> |
| 2698 | <p>Normalized into {summary.baseCurrency}; charts include textual values for accessibility.</p> |
| 2699 | </div> |
| 2700 | <LineChart size={20} aria-hidden="true" /> |
| 2701 | </div> |
| 2702 | <AllocationCharts summary={summary} /> |
| 2703 | </Card> |
| 2704 | ) : null} |
| 2705 | </div> |
| 2706 | ); |
| 2707 | } |
| 2708 | |
| 2709 | function PortfolioHistoryPanel({ |
| 2710 | history, |
| 2711 | range, |
| 2712 | loading, |
| 2713 | onRange |
| 2714 | }: { |
| 2715 | history: PortfolioHistory | null; |
| 2716 | range: PortfolioHistoryRange; |
| 2717 | loading: boolean; |
| 2718 | onRange: (value: PortfolioHistoryRange) => void; |
| 2719 | }) { |
| 2720 | const points = history?.points ?? []; |
| 2721 | const first = points[0]; |
| 2722 | const last = points[points.length - 1]; |
| 2723 | const absoluteChange = first && last ? last.marketValue.amount - first.marketValue.amount : undefined; |
| 2724 | const hasInvestedCapital = points.some((point) => point.investedCapital); |
| 2725 | |
| 2726 | return ( |
| 2727 | <Card className="wide-panel"> |
| 2728 | <div className="panel-header"> |
| 2729 | <div> |
| 2730 | <h2>Portfolio value history</h2> |
| 2731 | <p>{history?.investedCapitalStatus === "AVAILABLE" ? "Market value and invested capital." : "Portfolio value change; investment return needs cash-flow history."}</p> |
| 2732 | </div> |
| 2733 | <div className="range-control" aria-label="Portfolio history range"> |
| 2734 | {portfolioHistoryRanges.map((value) => ( |
| 2735 | <button |
| 2736 | className={range === value ? "range-active" : ""} |
| 2737 | key={value} |
| 2738 | onClick={() => onRange(value)} |
| 2739 | type="button" |
| 2740 | > |
| 2741 | {value} |
| 2742 | </button> |
| 2743 | ))} |
| 2744 | </div> |
| 2745 | </div> |
| 2746 | {loading ? ( |
| 2747 | <Skeleton rows={4} /> |
| 2748 | ) : points.length === 0 ? ( |
| 2749 | <EmptyState title="No portfolio history" message="No portfolio history available yet." /> |
| 2750 | ) : ( |
| 2751 | <> |
| 2752 | <section className="history-metrics" aria-label="Portfolio value change"> |
| 2753 | <MetricCard label="Starting value" value={formatBackendMoney(first?.marketValue)} /> |
| 2754 | <MetricCard label="Ending value" value={formatBackendMoney(last?.marketValue)} /> |
| 2755 | <MetricCard |
| 2756 | label="Portfolio value change" |
| 2757 | value={absoluteChange === undefined ? "--" : formatMoney(absoluteChange, last?.marketValue.currency)} |
| 2758 | tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"} |
| 2759 | /> |
| 2760 | <MetricCard |
| 2761 | label="Value change %" |
| 2762 | value={formatChangePercent(first?.marketValue.amount, last?.marketValue.amount)} |
| 2763 | tone={(absoluteChange ?? 0) >= 0 ? "positive" : "negative"} |
| 2764 | /> |
| 2765 | </section> |
| 2766 | <PortfolioHistoryChart points={points} showInvestedCapital={hasInvestedCapital} /> |
| 2767 | {points.length === 1 ? ( |
| 2768 | <p className="history-note">Portfolio history will build as real broker snapshots are collected.</p> |
| 2769 | ) : null} |
| 2770 | {history?.investedCapitalStatus !== "AVAILABLE" ? ( |
| 2771 | <p className="history-note">INVESTED_CAPITAL_HISTORY_UNAVAILABLE</p> |
| 2772 | ) : null} |
| 2773 | </> |
| 2774 | )} |
| 2775 | </Card> |
| 2776 | ); |
| 2777 | } |
| 2778 | |
| 2779 | function PortfolioHistoryChart({ |
| 2780 | points, |
| 2781 | showInvestedCapital |
| 2782 | }: { |
| 2783 | points: PortfolioHistory["points"]; |
| 2784 | showInvestedCapital: boolean; |
| 2785 | }) { |
| 2786 | const width = 900; |
| 2787 | const height = 260; |
| 2788 | const padding = { top: 18, right: 28, bottom: 34, left: 64 }; |
| 2789 | const marketValues = points.map((point) => point.marketValue.amount); |
| 2790 | const investedValues = showInvestedCapital |
| 2791 | ? points.map((point) => point.investedCapital?.amount).filter((value): value is number => value !== undefined) |
| 2792 | : []; |
| 2793 | const values = [...marketValues, ...investedValues]; |
| 2794 | const min = Math.min(...values); |
| 2795 | const max = Math.max(...values); |
| 2796 | const span = max - min || 1; |
| 2797 | const times = points.map((point) => new Date(point.timestamp).getTime()); |
| 2798 | const minTime = Math.min(...times); |
| 2799 | const maxTime = Math.max(...times); |
| 2800 | const timeSpan = maxTime - minTime || 1; |
| 2801 | const singlePoint = points.length === 1; |
| 2802 | const x = (timestamp: string) => |
| 2803 | singlePoint |
| 2804 | ? padding.left + (width - padding.left - padding.right) / 2 |
| 2805 | : padding.left + ((new Date(timestamp).getTime() - minTime) / timeSpan) * (width - padding.left - padding.right); |
| 2806 | const y = (value: number) => |
| 2807 | singlePoint |
| 2808 | ? padding.top + (height - padding.top - padding.bottom) / 2 |
| 2809 | : padding.top + (1 - (value - min) / span) * (height - padding.top - padding.bottom); |
| 2810 | const marketPath = points.length > 1 ? linePath(points.map((point) => [x(point.timestamp), y(point.marketValue.amount)])) : ""; |
| 2811 | const investedPath = showInvestedCapital && points.length > 1 |
| 2812 | ? linePath(points.filter((point) => point.investedCapital).map((point) => [x(point.timestamp), y(point.investedCapital?.amount ?? 0)])) |
| 2813 | : ""; |
| 2814 | const latest = points[points.length - 1]; |
| 2815 | |
| 2816 | return ( |
| 2817 | <div className="history-chart"> |
| 2818 | <svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Portfolio value history chart"> |
| 2819 | <line x1={padding.left} y1={padding.top} x2={padding.left} y2={height - padding.bottom} /> |
| 2820 | <line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} /> |
| 2821 | <text x={padding.left} y={14}>{formatMoney(max, latest?.marketValue.currency)}</text> |
| 2822 | <text x={padding.left} y={height - 8}>{formatMoney(min, latest?.marketValue.currency)}</text> |
| 2823 | {marketPath ? <path className="market-line" d={marketPath} /> : null} |
| 2824 | {investedPath ? <path className="invested-line" d={investedPath} /> : null} |
| 2825 | {points.map((point) => ( |
| 2826 | <circle className="market-point" cx={x(point.timestamp)} cy={y(point.marketValue.amount)} r={points.length === 1 ? 5 : 3} key={point.timestamp}> |
| 2827 | <title> |
| 2828 | {new Date(point.timestamp).toLocaleString()} | Portfolio {formatBackendMoney(point.marketValue)} |
| 2829 | {point.investedCapital ? ` | Invested ${formatBackendMoney(point.investedCapital)}` : ""} |
| 2830 | {` | P/L ${formatBackendMoney(point.unrealizedPnl)}`} |
| 2831 | </title> |
| 2832 | </circle> |
| 2833 | ))} |
| 2834 | </svg> |
| 2835 | </div> |
| 2836 | ); |
| 2837 | } |
| 2838 | |
| 2839 | function linePath(points: number[][]) { |
| 2840 | return points.map(([x, y], index) => `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`).join(" "); |
| 2841 | } |
| 2842 | |
| 2843 | function FreshnessBadge({ freshness }: { freshness: string }) { |
| 2844 | const label = freshness === "END_OF_DAY" ? "EOD" : freshness === "MOCK" ? "DEMO" : freshness.replaceAll("_", "-"); |
| 2845 | const tone = freshness === "REAL_TIME" || freshness === "REAL_BROKER" ? "positive" : freshness === "STALE" ? "warning" : freshness === "MOCK" ? "info" : freshness === "UNAVAILABLE" ? "negative" : "neutral"; |
| 2846 | return <Badge tone={tone}>{label}</Badge>; |
| 2847 | } |
| 2848 | |
| 2849 | function ResearchStatusBadge({ status }: { status?: string | null }) { |
| 2850 | const normalized = status ?? "UNAVAILABLE"; |
| 2851 | const label = normalized === "RESOLVED_RESEARCH_AVAILABLE" ? "Available" |
| 2852 | : normalized === "RESOLVED_PARTIAL_DATA" ? "Partial" |
| 2853 | : normalized === "ETF_UNSUPPORTED" || normalized === "RESEARCH_NOT_APPLICABLE" ? "ETF unsupported" |
| 2854 | : normalized.replaceAll("_", " "); |
| 2855 | const tone = normalized === "RESOLVED_RESEARCH_AVAILABLE" ? "positive" |
| 2856 | : normalized === "RESOLVED_PARTIAL_DATA" ? "warning" |
| 2857 | : normalized === "ETF_UNSUPPORTED" || normalized === "RESEARCH_NOT_APPLICABLE" ? "neutral" : "negative"; |
| 2858 | return <Badge tone={tone}>Research: {label}</Badge>; |
| 2859 | } |
| 2860 | |
| 2861 | function ResearchCoverage({ research }: { research: PortfolioResearchSummary | null }) { |
| 2862 | if (!research) return null; |
| 2863 | const companies = research.companies; |
| 2864 | const available = companies.filter((company) => company.status === "RESOLVED_RESEARCH_AVAILABLE").length; |
| 2865 | const partial = companies.filter((company) => company.status === "RESOLVED_PARTIAL_DATA").length; |
| 2866 | const etfUnsupported = companies.filter((company) => company.status === "ETF_UNSUPPORTED" || company.status === "RESEARCH_NOT_APPLICABLE").length; |
| 2867 | const fresh = companies.filter((company) => company.priceFreshness === "FRESH").length; |
| 2868 | const stale = companies.filter((company) => company.priceFreshness === "STALE").length; |
| 2869 | return <p className="research-coverage" aria-label="Portfolio research coverage"><strong>Research coverage:</strong> {companies.length} holdings · {available} available · {partial} partial · {etfUnsupported} ETF unsupported · {fresh} fresh · {stale} stale</p>; |
| 2870 | } |
| 2871 | |
| 2872 | function HoldingsTable({ positions, summary, portfolioResearch, onUpdateDisplayName }: { |
| 2873 | positions: PortfolioPosition[]; |
| 2874 | summary: PortfolioSummary | null; |
| 2875 | portfolioResearch: PortfolioResearchSummary | null; |
| 2876 | onUpdateDisplayName: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>; |
| 2877 | }) { |
| 2878 | const [detail, setDetail] = useState<{ position: PortfolioPosition; researchInstrumentId?: string | null } | null>(null); |
| 2879 | const manualMarketTotal = positions.some((position) => position.sourceType === "MANUAL_CSV_IMPORT") |
| 2880 | && positions.every((position) => position.marketValue) |
| 2881 | ? positions.reduce((total, position) => total + (position.marketValue?.amount ?? 0), 0) : null; |
| 2882 | |
| 2883 | function researchFor(position: PortfolioPosition) { |
| 2884 | return portfolioResearch?.companies.find((company) => |
| 2885 | company.instrumentId === position.instrument.globalInstrumentId |
| 2886 | || company.instrumentId === position.instrument.instrumentId |
| 2887 | || Boolean(position.instrument.isin && company.isin === position.instrument.isin) |
| 2888 | || (company.ticker === position.instrument.ticker && company.exchange === position.instrument.exchange) |
| 2889 | ); |
| 2890 | } |
| 2891 | |
| 2892 | return ( |
| 2893 | <div className="table-frame"> |
| 2894 | <table> |
| 2895 | <thead> |
| 2896 | <tr> |
| 2897 | <th>Company</th> |
| 2898 | <th>Ticker</th> |
| 2899 | <th>Quantity</th> |
| 2900 | <th>Average cost</th> |
| 2901 | <th>Latest price</th> |
| 2902 | <th>Market value</th> |
| 2903 | <th>Unrealized P/L</th> |
| 2904 | <th>Unrealized P/L %</th> |
| 2905 | <th>Allocation</th> |
| 2906 | <th>Currency</th> |
| 2907 | <th>Research & price status</th> |
| 2908 | </tr> |
| 2909 | </thead> |
| 2910 | <tbody> |
| 2911 | {positions.map((position) => { |
| 2912 | const allocation = manualMarketTotal !== null |
| 2913 | ? (manualMarketTotal === 0 || !position.marketValue ? null : position.marketValue.amount / manualMarketTotal * 100) |
| 2914 | : getAllocationValue(summary, position); |
| 2915 | const research = researchFor(position); |
| 2916 | const ownershipClass = research?.ownershipIncreases?.length |
| 2917 | ? `ownership-${research.ownershipIncreases.map((value) => value.toLowerCase().replace("_fpi", "")).join("-")}` |
| 2918 | : ""; |
| 2919 | const valuationClass = `valuation-${(research?.valuation.state ?? "UNKNOWN").toLowerCase()}`; |
| 2920 | return ( |
| 2921 | <tr className={`${valuationClass} ${ownershipClass}`.trim()} key={position.positionId} onClick={() => setDetail({ position, researchInstrumentId: research?.instrumentId ?? position.instrument.globalInstrumentId })}> |
| 2922 | <td> |
| 2923 | <button className="security-button" type="button" onClick={() => setDetail({ position, researchInstrumentId: research?.instrumentId ?? position.instrument.globalInstrumentId })}> |
| 2924 | <strong>{position.displayName}</strong> |
| 2925 | <span>{position.instrument.isin ?? "No ISIN"}</span> |
| 2926 | </button> |
| 2927 | </td> |
| 2928 | <td>{position.instrument.ticker}</td> |
| 2929 | <td>{position.quantity.toLocaleString("en")}</td> |
| 2930 | <td>{formatMoney(position.averageCost.amount, position.averageCost.currency)}</td> |
| 2931 | <td>{formatBackendMoney(position.currentPrice)}</td> |
| 2932 | <td>{formatBackendMoney(position.marketValue)}</td> |
| 2933 | <td className={position.unrealizedProfitLoss == null ? undefined : position.unrealizedProfitLoss.amount >= 0 ? "positive-text" : "negative-text"}> |
| 2934 | {formatBackendMoney(position.unrealizedProfitLoss)} |
| 2935 | </td> |
| 2936 | <td className={position.unrealizedProfitLossPercent == null ? undefined : position.unrealizedProfitLossPercent >= 0 ? "positive-text" : "negative-text"}> |
| 2937 | {formatPercent(position.unrealizedProfitLossPercent)} |
| 2938 | </td> |
| 2939 | <td>{formatPercent(allocation)}</td> |
| 2940 | <td>{position.instrument.tradingCurrency}</td> |
| 2941 | <td className="holding-status-cell"> |
| 2942 | <ResearchStatusBadge status={research?.status} /> |
| 2943 | <span>Position price: {position.dataFreshness === "IMPORTED_SNAPSHOT" ? "Imported snapshot" : position.dataFreshness.replaceAll("_", " ")}</span> |
| 2944 | <span>Live quote: <FreshnessBadge freshness={position.quote?.freshness ?? "UNAVAILABLE"} /></span> |
| 2945 | <span>Research price: <FreshnessBadge freshness={research?.priceFreshness ?? "UNKNOWN"} /></span> |
| 2946 | <span>Valuation: {(research?.valuation.state ?? "UNKNOWN").replaceAll("_", " ")}</span> |
| 2947 | {research?.ownershipIncreases?.length ? <span>Ownership increase: {research.ownershipIncreases.map((value) => value.replaceAll("_", "/")).join(", ")}</span> : <span>Ownership increase: None reported</span>} |
| 2948 | </td> |
| 2949 | </tr> |
| 2950 | ); |
| 2951 | })} |
| 2952 | </tbody> |
| 2953 | </table> |
| 2954 | {detail ? <StockResearchDrawer position={detail.position} research={detail.researchInstrumentId ? portfolioResearch?.companies.find((company) => company.instrumentId === detail.researchInstrumentId) : researchFor(detail.position)} onUpdateDisplayName={onUpdateDisplayName} onClose={() => setDetail(null)} /> : null} |
| 2955 | </div> |
| 2956 | ); |
| 2957 | } |
| 2958 | |
| 2959 | type MetricDisplay = { text: string; title?: string }; |
| 2960 | |
| 2961 | function metricDisplay(metric?: ProvenancedValue | null): MetricDisplay { |
| 2962 | if (!metric || metric.value === null || metric.value === undefined || metric.value === "") return { text: "N/A" }; |
| 2963 | const numeric = Number(metric.value); |
| 2964 | const unit = metric.unit?.trim(); |
| 2965 | if (unit?.toUpperCase() === "INR" && Number.isFinite(numeric)) { |
| 2966 | const exact = `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric)}`; |
| 2967 | const absolute = Math.abs(numeric); |
| 2968 | if (absolute >= 10_000_000) return { text: `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric / 10_000_000)} Cr`, title: exact }; |
| 2969 | if (absolute >= 100_000) return { text: `₹${new Intl.NumberFormat("en-IN", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric / 100_000)} Lakh`, title: exact }; |
| 2970 | return { text: exact }; |
| 2971 | } |
| 2972 | const value = Number.isFinite(numeric) ? new Intl.NumberFormat("en", { maximumFractionDigits: 2, minimumFractionDigits: 2 }).format(numeric) : String(metric.value); |
| 2973 | return { text: `${value}${unit === "percent" || unit === "%" ? "%" : unit && unit !== "ratio" ? ` ${unit}` : ""}` }; |
| 2974 | } |
| 2975 | |
| 2976 | function metricText(metric?: ProvenancedValue | null) { |
| 2977 | return metricDisplay(metric).text; |
| 2978 | } |
| 2979 | |
| 2980 | function MetricValue({ metric }: { metric?: ProvenancedValue | null }) { |
| 2981 | const display = metricDisplay(metric); |
| 2982 | return <span title={display.title}>{display.text}</span>; |
| 2983 | } |
| 2984 | |
| 2985 | function statementPeriod(value?: string | null) { |
| 2986 | return value ? value.slice(0, 10) : "N/A"; |
| 2987 | } |
| 2988 | |
| 2989 | function latestResultText(research?: PortfolioResearchCompany) { |
| 2990 | const result = research?.latestQuarterlyResult; |
| 2991 | if (!result) return research ? "Not publicly available" : "Awaiting research"; |
| 2992 | const growth = result.patYoYPercent ? ` PAT ${metricText(result.patYoYPercent)} YoY` : result.revenueYoYPercent ? ` Revenue ${metricText(result.revenueYoYPercent)} YoY` : ""; |
| 2993 | return `${statementPeriod(result.period)}${growth}`; |
| 2994 | } |
| 2995 | |
| 2996 | function valuationTone(state?: string): "neutral" | "positive" | "negative" | "warning" | "info" { |
| 2997 | if (state === "CHEAP") return "info"; |
| 2998 | if (state === "FAIR") return "positive"; |
| 2999 | if (state === "EXPENSIVE") return "negative"; |
| 3000 | return "neutral"; |
| 3001 | } |
| 3002 | |
| 3003 | function EvidenceMetric({ label, metric }: { label: string; metric?: ProvenancedValue | null }) { |
| 3004 | return ( |
| 3005 | <div className="research-metric"> |
| 3006 | <span>{label}</span><strong><MetricValue metric={metric} /></strong> |
| 3007 | {metric?.sourceUrl ? <a href={metric.sourceUrl} target="_blank" rel="noreferrer">{metric.sourceName}</a> : <small>Not publicly available</small>} |
| 3008 | {metric?.calculationBasis ? <small>{metric.calculationBasis}{metric.period ? ` · ${metric.period}` : ""}</small> : null} |
| 3009 | </div> |
| 3010 | ); |
| 3011 | } |
| 3012 | |
| 3013 | function structuredFact(research: PortfolioResearchCompany | undefined, key: string): ProvenancedValue | undefined { |
| 3014 | return research?.structuredMarket?.facts[key]; |
| 3015 | } |
| 3016 | |
| 3017 | function FinancialHistoryTable({ periods }: { periods: FinancialResultPeriod[] }) { |
| 3018 | if (!periods.length) return <p>Not publicly available.</p>; |
| 3019 | return <table className="shareholding-table financial-history-table"><thead><tr><th>Period</th><th>Basis</th><th>Revenue / Total Income</th><th>Operating Income</th><th>EBIT</th><th>EBITDA</th><th>PAT</th><th>EPS</th></tr></thead><tbody> |
| 3020 | {periods.map((period) => <tr key={`${period.periodType}:${period.period}:${period.reportingBasis ?? "UNKNOWN"}`}><td>{statementPeriod(period.period)}</td><td>{period.reportingBasis ?? "N/A"}</td><td><MetricValue metric={period.revenue} /></td><td><MetricValue metric={period.operatingIncome} /></td><td><MetricValue metric={period.ebit} /></td><td><MetricValue metric={period.ebitda} /></td><td><MetricValue metric={period.pat} /></td><td><MetricValue metric={period.eps} /></td></tr>)} |
| 3021 | </tbody></table>; |
| 3022 | } |
| 3023 | |
| 3024 | function StatementHistoryTable({ periods, labels }: { periods: FinancialStatementPeriod[]; labels: Array<[string[], string]> }) { |
| 3025 | if (!periods.length) return <p>Not publicly available.</p>; |
| 3026 | return <table className="shareholding-table financial-history-table"><thead><tr><th>Period</th><th>Basis</th>{labels.map(([, label]) => <th key={label}>{label}</th>)}</tr></thead><tbody> |
| 3027 | {periods.map((period) => <tr key={`${period.periodType}:${period.period}:${period.reportingBasis ?? "UNKNOWN"}`}><td>{statementPeriod(period.period)}</td><td>{period.reportingBasis ?? "N/A"}</td>{labels.map(([keys]) => <td key={keys.join("/")}><MetricValue metric={keys.map((key) => period.metrics[key]).find(Boolean)} /></td>)}</tr>)} |
| 3028 | </tbody></table>; |
| 3029 | } |
| 3030 | |
| 3031 | function DurableEvidenceSection({ title, evidence }: { title: string; evidence?: NonNullable<CatalystScore["categoryEvidence"]>[string] }) { |
| 3032 | const events = evidence?.supportingEvents ?? []; |
| 3033 | return <section><h3>{title}</h3>{events.length ? <><p>{events.length} verified item{events.length === 1 ? "" : "s"}</p>{events.map((event) => <article key={event.eventId}><strong>{statementPeriod(event.eventDate ?? event.publishedAt)} · {event.eventType.replaceAll("_", " ")}</strong>{event.summary ? <p>{event.summary}</p> : null}<a href={event.sourceUrl} target="_blank" rel="noreferrer">Source: {event.sourceType}</a></article>)}</> : <p>N/A — No verified evidence.</p>}</section>; |
| 3034 | } |
| 3035 | |
| 3036 | const shareholdingCategoryRows = [ |
| 3037 | ["PROMOTER", "Promoters"], |
| 3038 | ["PROMOTER_PLEDGE", "Promoter Pledge*"], |
| 3039 | ["FII_FPI", "FII / FPI"], |
| 3040 | ["DII", "DII"], |
| 3041 | ["PUBLIC_RETAIL", "Retail Public"], |
| 3042 | ["MUTUAL_FUNDS", "Mutual Funds"], |
| 3043 | ["INSURANCE", "Insurance"], |
| 3044 | ["GOVERNMENT", "Government"], |
| 3045 | ["OTHERS", "Others"], |
| 3046 | ] as const; |
| 3047 | |
| 3048 | const coreShareholdingCategories = new Set(["PROMOTER", "FII_FPI", "DII", "PUBLIC_RETAIL"]); |
| 3049 | |
| 3050 | function shareholdingPeriodLabel(periodEnd: string): string { |
| 3051 | return new Intl.DateTimeFormat("en", { month: "short", year: "numeric", timeZone: "UTC" }).format(new Date(periodEnd)); |
| 3052 | } |
| 3053 | |
| 3054 | function shareholdingPercentage(value: string | undefined): string { |
| 3055 | if (value === undefined) return "—"; |
| 3056 | const percentage = Number(value); |
| 3057 | return Number.isFinite(percentage) ? `${percentage.toFixed(2)}%` : "—"; |
| 3058 | } |
| 3059 | |
| 3060 | function ShareholdingPatternTable({ snapshots }: { snapshots: NonNullable<PortfolioResearchCompany["shareholdingSnapshots"]> }) { |
| 3061 | const periods = [...snapshots].slice(0, 4).sort((left, right) => |
| 3062 | new Date(left.periodEnd).getTime() - new Date(right.periodEnd).getTime()); |
| 3063 | const rows = shareholdingCategoryRows.filter(([category]) => |
| 3064 | coreShareholdingCategories.has(category) || periods.some((snapshot) => snapshot.values.some((value) => value.category === category))); |
| 3065 | const newest = periods[periods.length - 1]; |
| 3066 | const hasPromoterPledge = rows.some(([category]) => category === "PROMOTER_PLEDGE"); |
| 3067 | |
| 3068 | return <> |
| 3069 | <div className="shareholding-table-frame"> |
| 3070 | <table className="shareholding-table"> |
| 3071 | <thead><tr><th scope="col">Category</th>{periods.map((snapshot) => <th scope="col" key={snapshot.id}>{shareholdingPeriodLabel(snapshot.periodEnd)}</th>)}</tr></thead> |
| 3072 | <tbody>{rows.map(([category, label]) => <tr key={category}> |
| 3073 | <th scope="row" title={category === "PUBLIC_RETAIL" ? "Resident individual shareholders holding nominal share capital up to ₹2 lakh." : undefined}>{label}</th> |
| 3074 | {periods.map((snapshot) => { |
| 3075 | const value = snapshot.values.find((candidate) => candidate.category === category); |
| 3076 | return <td key={snapshot.id}>{shareholdingPercentage(value?.percentage)}</td>; |
| 3077 | })} |
| 3078 | </tr>)}</tbody> |
| 3079 | </table> |
| 3080 | </div> |
| 3081 | {hasPromoterPledge ? <p className="shareholding-note">* % of promoter holding</p> : null} |
| 3082 | {newest?.sourceUrl ? <p className="shareholding-source">Source: <a href={newest.sourceUrl} target="_blank" rel="noreferrer">{newest.sourceProvider} Shareholding XBRL</a></p> : null} |
| 3083 | </>; |
| 3084 | } |
| 3085 | |
| 3086 | function isFinancialCompany(research: PortfolioResearchCompany | undefined): boolean { |
| 3087 | const identity = `${structuredFact(research, "sector")?.value ?? ""} ${structuredFact(research, "industry")?.value ?? ""}`.toLowerCase(); |
| 3088 | return /financial|bank|insurance|credit|capital market/.test(identity); |
| 3089 | } |
| 3090 | |
| 3091 | function StructuredMetric({ label, research, fact }: { label: string; research?: PortfolioResearchCompany; fact: string }) { |
| 3092 | return <EvidenceMetric label={label} metric={structuredFact(research, fact)} />; |
| 3093 | } |
| 3094 | |
| 3095 | function StockResearchDrawer({ position, watchlistItem, research, onUpdateDisplayName, onClose }: { position?: PortfolioPosition; watchlistItem?: WatchlistResearchInstrument; research?: PortfolioResearchCompany; onUpdateDisplayName?: (position: PortfolioPosition, customDisplayName: string | null) => Promise<void>; onClose: () => void }) { |
| 3096 | const instrument = position?.instrument; |
| 3097 | const displayName = position?.displayName ?? research?.companyName ?? "Watchlist instrument"; |
| 3098 | const ticker = instrument?.ticker ?? research?.ticker ?? "Ticker N/A"; |
| 3099 | const exchange = instrument?.exchange ?? research?.exchange ?? "Exchange N/A"; |
| 3100 | const isin = instrument?.isin ?? research?.isin; |
| 3101 | const country = instrument?.country ?? watchlistItem?.country ?? undefined; |
| 3102 | const currency = research?.structuredMarket?.resolution.currency ?? instrument?.tradingCurrency ?? watchlistItem?.currency ?? undefined; |
| 3103 | const assetType = instrument?.assetType ?? research?.assetType ?? watchlistItem?.assetType ?? "EQUITY"; |
| 3104 | const result = research?.latestQuarterlyResult; |
| 3105 | const financial = isFinancialCompany(research); |
| 3106 | const isEtf = assetType === "ETF" || research?.structuredMarket?.resolution.quoteType === "ETF"; |
| 3107 | const market = research?.structuredMarket; |
| 3108 | const latestPriceFact = structuredFact(research, "latestPrice"); |
| 3109 | const researchCurrentPrice = |
| 3110 | latestPriceFact?.value != null |
| 3111 | ? Number(latestPriceFact.value) |
| 3112 | : research?.currentPrice == null |
| 3113 | ? null |
| 3114 | : Number(research.currentPrice); |
| 3115 | const currentPrice = researchCurrentPrice != null && Number.isFinite(researchCurrentPrice) && researchCurrentPrice > 0 |
| 3116 | ? formatMoney(researchCurrentPrice, currency) |
| 3117 | : position?.currentPrice && position.currentPrice.amount > 0 |
| 3118 | ? formatBackendMoney(position.currentPrice) |
| 3119 | : "N/A"; |
| 3120 | const [editing, setEditing] = useState(false); |
| 3121 | const [name, setName] = useState(position?.customDisplayName ?? displayName); |
| 3122 | const [saving, setSaving] = useState(false); |
| 3123 | const [renameError, setRenameError] = useState<string | null>(null); |
| 3124 | async function saveName() { |
| 3125 | if (!name.trim()) { setRenameError("Enter a display name."); return; } |
| 3126 | setSaving(true); setRenameError(null); |
| 3127 | try { if (onUpdateDisplayName && position) await onUpdateDisplayName(position, name.trim()); setEditing(false); } |
| 3128 | catch (error) { setRenameError(getApiFailure(error).message); } |
| 3129 | finally { setSaving(false); } |
| 3130 | } |
| 3131 | return ( |
| 3132 | <div className="research-drawer-backdrop" role="presentation" onClick={onClose}> |
| 3133 | <aside className="research-drawer" role="dialog" aria-modal="true" aria-label={`${displayName} research details`} onClick={(event) => event.stopPropagation()}> |
| 3134 | <div className="panel-header"><div><p className="eyebrow">Stock research</p><h2>{displayName}</h2><p>{ticker} · {exchange} · {isin ?? "ISIN N/A"}</p></div><button className="icon-button" type="button" aria-label="Close stock research" onClick={onClose}><X size={20} /></button></div> |
| 3135 | <section><h3>Overview</h3>{editing && position ? <div className="holding-name-editor"><label>Display name<input maxLength={160} value={name} onChange={(event) => setName(event.target.value)} /></label><Button disabled={saving} onClick={() => void saveName()}>Save</Button><Button variant="secondary" disabled={saving} onClick={() => { setEditing(false); setName(position.customDisplayName ?? position.displayName); setRenameError(null); }}>Cancel</Button>{renameError ? <small role="alert">{renameError}</small> : null}</div> : <p><strong>{displayName}</strong> {position?.sourceType === "MANUAL_CSV_IMPORT" && onUpdateDisplayName ? <button className="text-action" type="button" onClick={() => setEditing(true)}>Edit</button> : null}</p>}<p>{ticker} · {isin ?? "ISIN N/A"} · {market?.resolution.exchange ?? exchange} · {country ?? "Country N/A"} · {currency ?? "Currency N/A"} · {market?.resolution.quoteType ?? assetType}</p><p>Provider ticker: {market?.resolution.providerTicker ?? "N/A"} · Provider identity: {market?.resolution.companyName ?? "N/A"}</p><p>Sector: {metricText(structuredFact(research, "sector"))} · Industry: {metricText(structuredFact(research, "industry"))}</p><p>{position ? <>Broker/source: {brokerDisplayName(position.brokerType)} · {position.sourceType}</> : <>Watchlist status: Not held</>}</p></section> |
| 3136 | {position ? <section><h3>Position</h3><div className="research-metric-grid"><div className="research-metric"><span>Quantity</span><strong>{position.quantity}</strong></div><div className="research-metric"><span>Average Cost</span><strong>{formatBackendMoney(position.averageCost)}</strong></div><div className="research-metric"><span>Cost Basis</span><strong>{formatBackendMoney(position.costBasis)}</strong></div><div className="research-metric"><span>Imported Price</span><strong>{formatBackendMoney(position.importedPrice)}</strong></div><div className="research-metric"><span>Latest Market Price</span><strong>{formatBackendMoney(position.currentPrice)}</strong></div><div className="research-metric"><span>Market Value</span><strong>{formatBackendMoney(position.marketValue)}</strong></div><div className="research-metric"><span>Unrealized P/L</span><strong>{formatBackendMoney(position.unrealizedProfitLoss)}</strong></div></div></section> : <section><h3>Watchlist status</h3><p>Public company research</p><div className="research-metric-grid">{watchlistItem?.sourcePeriod ? <div className="research-metric"><span>Market return ({watchlistItem.sourcePeriod})</span><strong className={`${performanceRowTone(watchlistItem.sourcePerformancePct)}-text`}>{formatSignedPerformancePct(watchlistItem.sourcePerformancePct)}</strong></div> : null}</div></section>} |
| 3137 | <section><h3>Market Data</h3><div className="research-metric-grid"><div className="research-metric"><span>Latest Price</span><strong>{currentPrice}</strong></div><EvidenceMetric label="Previous close" metric={structuredFact(research, "previousClose")} /><EvidenceMetric label="Bid" metric={structuredFact(research, "bid")} /><EvidenceMetric label="Ask" metric={structuredFact(research, "ask")} /><EvidenceMetric label="Volume" metric={structuredFact(research, "volume")} /><EvidenceMetric label="10-day avg volume" metric={structuredFact(research, "averageVolume10Day")} /><EvidenceMetric label="3-month avg volume" metric={structuredFact(research, "averageVolume")} /><EvidenceMetric label="52-week low" metric={structuredFact(research, "fiftyTwoWeekLow")} /><EvidenceMetric label="52-week high" metric={structuredFact(research, "fiftyTwoWeekHigh")} /><div className="research-metric"><span>Price freshness</span><strong>{research?.priceFreshness ?? "UNKNOWN"}</strong></div><div className="research-metric"><span>Market As Of</span><strong>{market?.marketAsOf ? new Date(market.marketAsOf).toLocaleString() : position?.quote?.sourceTimestamp ? new Date(position.quote.sourceTimestamp).toLocaleString() : "N/A"}</strong></div><div className="research-metric"><span>Retrieved At</span><strong>{market?.retrievedAt ? new Date(market.retrievedAt).toLocaleString() : position?.quote?.receivedAt ? new Date(position.quote.receivedAt).toLocaleString() : "N/A"}</strong></div><div className="research-metric"><span>Provider</span><strong>{market?.sourceName ?? position?.quote?.source ?? "N/A"}</strong></div></div></section> |
| 3138 | <section><h3>Valuation</h3><div className="research-metric-grid"><StructuredMetric label="Market cap" research={research} fact="marketCap" /><StructuredMetric label="Enterprise value" research={research} fact="enterpriseValue" /><StructuredMetric label="Trailing P/E" research={research} fact="trailingPE" /><StructuredMetric label="Forward P/E" research={research} fact="forwardPE" /><StructuredMetric label="P/B" research={research} fact="priceToBook" /><StructuredMetric label="P/S" research={research} fact="priceToSales" /><StructuredMetric label="PEG" research={research} fact="pegRatio" />{!financial && !isEtf ? <><StructuredMetric label="EV/revenue" research={research} fact="evToRevenue" /><StructuredMetric label="EV/EBITDA" research={research} fact="evToEbitda" /></> : null}<StructuredMetric label="Trailing EPS" research={research} fact="trailingEps" /><StructuredMetric label="Forward EPS" research={research} fact="forwardEps" /></div><p><Badge tone={valuationTone(research?.valuation?.state ?? "UNKNOWN")}>{research?.valuation?.state ?? "UNKNOWN"}</Badge> {research?.valuation?.reason ?? "Awaiting contextual public research."}</p></section> |
| 3139 | {!isEtf ? <section><h3>Quality / Fundamentals</h3><div className="research-metric-grid"><EvidenceMetric label="ROE" metric={structuredFact(research, "roe") ?? research?.valuation?.roe} /><StructuredMetric label="ROA" research={research} fact="roa" />{!financial ? <EvidenceMetric label="ROCE" metric={structuredFact(research, "roce") ?? research?.valuation?.roce} /> : null}<StructuredMetric label="Operating margin" research={research} fact="operatingMargin" /><StructuredMetric label="Net margin" research={research} fact="profitMargin" /><StructuredMetric label="Revenue growth" research={research} fact="revenueGrowth" /><StructuredMetric label="Earnings growth" research={research} fact="earningsGrowth" /><StructuredMetric label="Cash" research={research} fact="totalCash" /><StructuredMetric label="Debt" research={research} fact="totalDebt" /><StructuredMetric label="Debt / equity" research={research} fact="debtToEquity" /><StructuredMetric label="Free cash flow" research={research} fact="freeCashFlow" /><StructuredMetric label="Operating cash flow" research={research} fact="operatingCashFlow" /></div></section> : null} |
| 3140 | <section><h3>Analyst View</h3><p>External public analyst consensus; not an application recommendation.</p><div className="research-metric-grid"><div className="research-metric"><span>Current Price</span><strong>{currentPrice}</strong></div><StructuredMetric label="Target low" research={research} fact="publicAnalystTargetLowPrice" /><StructuredMetric label="Target median" research={research} fact="publicAnalystTargetMedianPrice" /><StructuredMetric label="Target mean" research={research} fact="publicAnalystTargetMeanPrice" /><StructuredMetric label="Target high" research={research} fact="publicAnalystTargetHighPrice" /><StructuredMetric label="Number of analysts" research={research} fact="publicAnalystCount" /><StructuredMetric label="Consensus" research={research} fact="publicAnalystConsensus" /><StructuredMetric label="Consensus score" research={research} fact="publicAnalystRecommendationMean" /></div></section> |
| 3141 | <section><h3>Latest Quarterly Result</h3>{result ? <><p>{result.documentTitle ?? "Quarterly financial result"} · {statementPeriod(result.period)} · {result.reportingBasis ?? "Reporting basis N/A"} · {result.resultDate ? statementPeriod(result.resultDate) : "Result date N/A"}</p><div className="research-metric-grid">{financial ? <><EvidenceMetric label="Total income" metric={result.revenue} /><EvidenceMetric label="PAT / net profit" metric={result.pat} /><EvidenceMetric label="EPS" metric={result.eps} /><EvidenceMetric label="NIM" metric={result.nim} /><EvidenceMetric label="ROA" metric={result.roa} /><EvidenceMetric label="ROE" metric={result.roe} /><EvidenceMetric label="Gross NPA" metric={result.grossNpa} /><EvidenceMetric label="Net NPA" metric={result.netNpa} /><EvidenceMetric label="Deposits" metric={result.deposits} /><EvidenceMetric label="Advances" metric={result.advances} /><EvidenceMetric label="Capital adequacy" metric={result.capitalAdequacy} /><EvidenceMetric label="Credit cost" metric={result.creditCost} /></> : <><EvidenceMetric label="Revenue" metric={result.revenue} /><EvidenceMetric label="Revenue YoY" metric={result.revenueYoYPercent} /><EvidenceMetric label="EBITDA / operating profit" metric={result.ebitda} /><EvidenceMetric label="EBITDA / operating margin" metric={result.ebitdaMargin} /><EvidenceMetric label="PAT / net profit" metric={result.pat} /><EvidenceMetric label="PAT YoY" metric={result.patYoYPercent} /><EvidenceMetric label="EPS" metric={result.eps} /><EvidenceMetric label="Debt / borrowings" metric={result.debtOrBorrowings} /></>}</div>{result.yoySummary ? <p>{result.yoySummary}</p> : null}<p>Source: {filingSourceLabel(result.sourceName)} · Published {result.publishedAt ? new Date(result.publishedAt).toLocaleDateString() : "N/A"} · Retrieved {new Date(result.retrievedAt).toLocaleString()}</p><a href={result.sourceUrl} target="_blank" rel="noreferrer">View {filingSourceLabel(result.sourceName)} Filing ↗</a></> : <p>{research?.quarterlyResultStatus === "PDF_SCANNED_OCR_REQUIRED" ? "PDF scanned; OCR required." : "Not publicly available."}</p>}</section> |
| 3142 | {!isEtf ? <section><h3>Financial History</h3><h4>Quarterly</h4><FinancialHistoryTable periods={(research?.financialResultHistory ?? []).filter((period) => period.periodType === "QUARTERLY").slice(0, 4)} /><h4>Annual</h4><FinancialHistoryTable periods={(research?.financialResultHistory ?? []).filter((period) => period.periodType === "ANNUAL")} /></section> : null} |
| 3143 | {(country === "IN" || exchange === "NSE" || exchange === "XNSE") ? <section><h3>Shareholding Pattern</h3>{research?.shareholdingSnapshots?.length ? <ShareholdingPatternTable snapshots={research.shareholdingSnapshots} /> : research?.shareholdingChanges?.length ? research.shareholdingChanges.map((change) => <p className={Number(change.changePercentagePoints) >= 0.1 ? `ownership-increase ownership-${change.category.toLowerCase()}` : undefined} key={change.category}><strong>{change.category.replaceAll("_", "/")}</strong>: {metricText(change.current)} ({Number(change.changePercentagePoints) >= 0 ? "+" : ""}{change.changePercentagePoints} pp), {change.previousPeriod} → {change.currentPeriod} · <a href={change.current.sourceUrl} target="_blank" rel="noreferrer">Source</a></p>) : <p>Unavailable.</p>}</section> : null} |
| 3144 | {!isEtf ? <section><h3>Debt & Balance Sheet</h3><StatementHistoryTable periods={research?.balanceSheetHistory ?? []} labels={[[["total_assets"], "Total Assets"], [["total_liabilities"], "Total Liabilities"], [["total_equity", "equity"], "Equity / Net Worth"], [["cash_and_cash_equivalents", "cash_and_equivalents"], "Cash / Cash Equivalents"], [["total_debt", "debt_or_borrowings"], "Total Debt"], [["current_assets"], "Current Assets"], [["current_liabilities"], "Current Liabilities"]]} /></section> : null} |
| 3145 | {!isEtf ? <section><h3>Cash Flow</h3><StatementHistoryTable periods={research?.cashFlowHistory ?? []} labels={[[["operating_cash_flow", "cash_flow_from_operating_activities"], "Operating Cash Flow"], [["investing_cash_flow", "cash_flow_from_investing_activities"], "Investing Cash Flow"], [["financing_cash_flow", "cash_flow_from_financing_activities"], "Financing Cash Flow"], [["capex"], "Capital Expenditure / Capex"]]} /></section> : null} |
| 3146 | <section><h3>Current Quarter Catalysts</h3>{research?.currentQuarterCatalysts?.length ? research.currentQuarterCatalysts.map((event) => <article key={event.eventId}><strong>{event.eventDate ?? event.publishedAt?.slice(0, 10) ?? "Date unavailable"} · {event.eventType.replaceAll("_", " ")}</strong><p>{event.summary}</p><a href={event.sourceUrl} target="_blank" rel="noreferrer">Source: {event.sourceType}</a></article>) : <p>No verified current-quarter catalyst.</p>}</section> |
| 3147 | <DurableEvidenceSection title="Orders & Backlog" evidence={research?.durableCategoryEvidence?.ORDERS_BACKLOG} /> |
| 3148 | <DurableEvidenceSection title="CAPEX & Capacity" evidence={research?.durableCategoryEvidence?.CAPEX} /> |
| 3149 | <DurableEvidenceSection title="Customers" evidence={research?.durableCategoryEvidence?.CLIENTS} /> |
| 3150 | <DurableEvidenceSection title="Catalysts" evidence={research?.durableCategoryEvidence?.CATALYSTS} /> |
| 3151 | <section><h3>News</h3>{market?.news?.length ? market.news.map((article) => <article key={article.url}><strong>{article.headline}</strong><p>{article.publisher} · {article.publishedAt ? new Date(article.publishedAt).toLocaleString() : "Date unavailable"}</p><a href={article.url} target="_blank" rel="noreferrer">Open source</a></article>) : <p>No public provider news available.</p>}</section> |
| 3152 | <section><h3>Research & Evidence</h3><p>Status: {research?.status?.replaceAll("_", " ") ?? "Awaiting research"}; {research?.sourceDiversity?.domainsFound ?? 0} unique domains, {research?.sourceDiversity?.exchangeSources ?? 0} exchange sources, {research?.sourceDiversity?.companySources ?? 0} company sources, {research?.sourceDiversity?.secondarySources ?? 0} secondary sources.</p>{research?.latestEvent ? <a href={research.latestEvent.sourceUrl} target="_blank" rel="noreferrer">Latest evidence source</a> : null}</section> |
| 3153 | </aside> |
| 3154 | </div> |
| 3155 | ); |
| 3156 | } |
| 3157 | |
| 3158 | function AllocationCharts({ summary }: { summary: PortfolioSummary }) { |
| 3159 | return ( |
| 3160 | <div className="allocation-grid"> |
| 3161 | <AllocationGroup title="Country" values={summary.allocation.country} /> |
| 3162 | <AllocationGroup title="Sector" values={summary.allocation.sector} /> |
| 3163 | <AllocationGroup title="Currency" values={summary.allocation.currency} /> |
| 3164 | <AllocationGroup title="Asset type" values={summary.allocation.assetType} /> |
| 3165 | <AllocationGroup title="Broker" values={summary.allocation.broker} /> |
| 3166 | </div> |
| 3167 | ); |
| 3168 | } |
| 3169 | |
| 3170 | function AllocationGroup({ title, values }: { title: string; values: Record<string, number> }) { |
| 3171 | const entries = Object.entries(values).sort(([, a], [, b]) => b - a).slice(0, 5); |
| 3172 | |
| 3173 | return ( |
| 3174 | <section className="allocation-group" aria-label={`${title} allocation`}> |
| 3175 | <h3>{title}</h3> |
| 3176 | {entries.length === 0 ? ( |
| 3177 | <p>No allocation data.</p> |
| 3178 | ) : ( |
| 3179 | entries.map(([label, value]) => ( |
| 3180 | <div className="allocation-row" key={label}> |
| 3181 | <div> |
| 3182 | <span>{label}</span> |
| 3183 | <strong>{formatPercent(value)}</strong> |
| 3184 | </div> |
| 3185 | <div className="bar-track" aria-hidden="true"> |
| 3186 | <span style={{ width: `${Math.min(value, 100)}%` }} /> |
| 3187 | </div> |
| 3188 | </div> |
| 3189 | )) |
| 3190 | )} |
| 3191 | </section> |
| 3192 | ); |
| 3193 | } |
| 3194 | |
| 3195 | function BrokerView({ |
| 3196 | providers, |
| 3197 | connections, |
| 3198 | portfolios, |
| 3199 | errors, |
| 3200 | loading, |
| 3201 | authenticatingBroker, |
| 3202 | authenticationTimedOut, |
| 3203 | onContinueAuthentication, |
| 3204 | onCancelAuthentication, |
| 3205 | onConnectBroker, |
| 3206 | onAuthenticate, |
| 3207 | onConfigureIndividual, |
| 3208 | onImportComplete, |
| 3209 | onDisconnect |
| 3210 | }: { |
| 3211 | providers: BrokerProviderInfo[]; |
| 3212 | connections: BrokerConnection[]; |
| 3213 | portfolios: PortfolioListItem[]; |
| 3214 | errors: Record<string, string>; |
| 3215 | loading: boolean; |
| 3216 | authenticatingBroker: string | null; |
| 3217 | authenticationTimedOut: boolean; |
| 3218 | onContinueAuthentication: () => void; |
| 3219 | onCancelAuthentication: () => void; |
| 3220 | onConnectBroker: (provider: BrokerProviderInfo) => Promise<void>; |
| 3221 | onAuthenticate: (connectionId: string, provider: BrokerProviderInfo) => Promise<void>; |
| 3222 | onConfigureIndividual: (provider: BrokerProviderInfo, clientKey: string, clientSecret: string) => Promise<void>; |
| 3223 | onImportComplete: (portfolioId: string) => Promise<void>; |
| 3224 | onDisconnect: (connectionId: string) => Promise<void>; |
| 3225 | }) { |
| 3226 | const [developerProvider, setDeveloperProvider] = useState<string | null>(null); |
| 3227 | const [developerKey, setDeveloperKey] = useState(""); |
| 3228 | const [developerSecret, setDeveloperSecret] = useState(""); |
| 3229 | const [importProvider, setImportProvider] = useState<string | null>(null); |
| 3230 | const [importFile, setImportFile] = useState<File | null>(null); |
| 3231 | const [importName, setImportName] = useState(""); |
| 3232 | const [importPreview, setImportPreview] = useState<PortfolioImportPreview | null>(null); |
| 3233 | const [importBusy, setImportBusy] = useState(false); |
| 3234 | const [importError, setImportError] = useState<string | null>(null); |
| 3235 | const [importSuccess, setImportSuccess] = useState(false); |
| 3236 | const visibleProviders = providers.filter((provider) => provider.brokerType !== "MOCK"); |
| 3237 | if (visibleProviders.length === 0) return <EmptyState title="No brokers available" message="Broker discovery is unavailable." />; |
| 3238 | return ( |
| 3239 | <div> |
| 3240 | <div className="broker-grid"> |
| 3241 | {visibleProviders.map((provider) => { |
| 3242 | const activeConnection = connections.find((connection) => connection.brokerType === provider.brokerType); |
| 3243 | const linkedPortfolio = activeConnection |
| 3244 | ? portfolios.find((portfolio) => portfolio.brokerConnectionId === activeConnection.connectionId) |
| 3245 | : undefined; |
| 3246 | const authenticationRequired = activeConnection?.status === "AUTHENTICATION_REQUIRED" |
| 3247 | || activeConnection?.providerStatus === "AUTHENTICATION_REQUIRED"; |
| 3248 | const connected = activeConnection?.status === "CONNECTED" |
| 3249 | && (!activeConnection.providerStatus || activeConnection.providerStatus === "CONNECTED"); |
| 3250 | const recoverable = Boolean(activeConnection) && !connected; |
| 3251 | const connectionError = activeConnection?.status === "ERROR" |
| 3252 | || activeConnection?.providerStatus === "ERROR" |
| 3253 | || activeConnection?.lastErrorCode === "BROKER_UNAVAILABLE"; |
| 3254 | const partnerUnavailable = provider.consumerAuthMode === "PARTNER_UNAVAILABLE"; |
| 3255 | const developerIndividualMode = provider.individualApiSupported && provider.advancedIndividualMode; |
| 3256 | const meaningfulPersistedLinkage = Boolean(linkedPortfolio || activeConnection?.lastSuccessfulSyncAt || connected); |
| 3257 | const authenticationPending = authenticatingBroker === provider.brokerType; |
| 3258 | const statusLabel = partnerUnavailable ? "Direct connection unavailable" |
| 3259 | : !provider.connectable ? "Unavailable" |
| 3260 | : authenticationPending && authenticationTimedOut ? "Authentication pending" |
| 3261 | : authenticationPending ? "Waiting for IBKR authentication…" |
| 3262 | : authenticationRequired ? "Authentication required" |
| 3263 | : connected ? "Connected" |
| 3264 | : connectionError ? "Connection error" |
| 3265 | : activeConnection ? "Not connected" : "Not connected"; |
| 3266 | const message = partnerUnavailable |
| 3267 | ? "Direct customer account connection is not available yet." |
| 3268 | : !provider.connectable |
| 3269 | ? provider.unavailableReason ?? "Connection not available yet." |
| 3270 | : authenticationPending && authenticationTimedOut |
| 3271 | ? "IBKR authentication is still pending." |
| 3272 | : authenticationPending |
| 3273 | ? "Waiting for IBKR authentication…" |
| 3274 | : authenticationRequired |
| 3275 | ? "Authentication is required to refresh holdings. Your saved portfolio remains available." |
| 3276 | : connected ? "Your broker connection is ready. Sync holdings from the linked portfolio." |
| 3277 | : connectionError ? "Broker authentication is temporarily unavailable. Your saved portfolio remains available." |
| 3278 | : "Connect this broker to import read-only holdings."; |
| 3279 | return ( |
| 3280 | <Card className="broker-card" as="article" key={provider.brokerType}> |
| 3281 | <div className="broker-icon"> |
| 3282 | <Building2 size={22} aria-hidden="true" /> |
| 3283 | </div> |
| 3284 | <div className="broker-card-content"> |
| 3285 | <div className="panel-header compact"> |
| 3286 | <h2>{provider.displayName}</h2> |
| 3287 | <Badge tone={connected ? "positive" : "warning"}>{statusLabel}</Badge> |
| 3288 | <Badge tone="positive">Read-only</Badge> |
| 3289 | </div> |
| 3290 | <p className="broker-message">{message}</p> |
| 3291 | {!partnerUnavailable && errors[provider.brokerType] ? <div className="broker-inline-error" role="alert">{errors[provider.brokerType]}</div> : null} |
| 3292 | {linkedPortfolio ? <p className="broker-linked-portfolio">Portfolio: <strong>{linkedPortfolio.name}</strong></p> : null} |
| 3293 | <div className="broker-actions"> |
| 3294 | {!authenticationPending && !partnerUnavailable && !developerIndividualMode && !activeConnection && provider.connectable ? ( |
| 3295 | <Button onClick={() => onConnectBroker(provider)} disabled={loading}> |
| 3296 | {loading ? "Connecting..." : provider.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"} |
| 3297 | </Button> |
| 3298 | ) : null} |
| 3299 | {!authenticationPending && !partnerUnavailable && !developerIndividualMode && activeConnection && recoverable ? ( |
| 3300 | <Button onClick={() => onAuthenticate(activeConnection.connectionId, provider)} disabled={loading}> |
| 3301 | {loading ? "Opening sign-in..." : activeConnection.brokerType === "IBKR" ? "Connect / Re-authenticate" : "Connect"} |
| 3302 | </Button> |
| 3303 | ) : null} |
| 3304 | {authenticationPending && !authenticationTimedOut ? ( |
| 3305 | <Button variant="ghost" onClick={onCancelAuthentication}>Cancel authentication</Button> |
| 3306 | ) : null} |
| 3307 | {authenticationPending && authenticationTimedOut ? ( |
| 3308 | <> |
| 3309 | <Button variant="secondary" onClick={onContinueAuthentication}>Continue checking</Button> |
| 3310 | {activeConnection ? <Button onClick={() => { |
| 3311 | onCancelAuthentication(); |
| 3312 | void onAuthenticate(activeConnection.connectionId, provider); |
| 3313 | }}>Try again</Button> : null} |
| 3314 | <Button variant="ghost" onClick={onCancelAuthentication}>Cancel</Button> |
| 3315 | </> |
| 3316 | ) : null} |
| 3317 | {activeConnection && connected ? <Button variant="secondary" disabled>Manage</Button> : null} |
| 3318 | {activeConnection && (!partnerUnavailable || meaningfulPersistedLinkage) ? ( |
| 3319 | <Button variant="ghost" onClick={() => onDisconnect(activeConnection.connectionId)} disabled={loading}>Disconnect</Button> |
| 3320 | ) : null} |
| 3321 | {developerIndividualMode ? ( |
| 3322 | <Button variant="secondary" onClick={() => setDeveloperProvider( |
| 3323 | developerProvider === provider.brokerType ? null : provider.brokerType |
| 3324 | )} disabled={loading}>Configure DEV API</Button> |
| 3325 | ) : null} |
| 3326 | {provider.manualImportSupported ? ( |
| 3327 | <Button variant="secondary" onClick={() => { |
| 3328 | setImportProvider(importProvider === provider.brokerType ? null : provider.brokerType); |
| 3329 | setImportFile(null); setImportPreview(null); setImportError(null); setImportName(""); |
| 3330 | }} disabled={loading}>Import portfolio</Button> |
| 3331 | ) : null} |
| 3332 | </div> |
| 3333 | {developerIndividualMode && developerProvider === provider.brokerType ? ( |
| 3334 | <form className="broker-developer-form" onSubmit={async (event) => { |
| 3335 | event.preventDefault(); |
| 3336 | await onConfigureIndividual(provider, developerKey, developerSecret); |
| 3337 | setDeveloperKey(""); |
| 3338 | setDeveloperSecret(""); |
| 3339 | setDeveloperProvider(null); |
| 3340 | }}> |
| 3341 | <p>Developer/test individual API setup. Credentials are stored write-only.</p> |
| 3342 | <label>API key<input type="password" autoComplete="off" value={developerKey} onChange={(event) => setDeveloperKey(event.target.value)} required /></label> |
| 3343 | <label>API secret<input type="password" autoComplete="new-password" value={developerSecret} onChange={(event) => setDeveloperSecret(event.target.value)} required /></label> |
| 3344 | <Button type="submit" disabled={loading}>Save DEV credentials</Button> |
| 3345 | </form> |
| 3346 | ) : null} |
| 3347 | {provider.manualImportSupported && importProvider === provider.brokerType ? ( |
| 3348 | <div className="import-modal-backdrop" role="presentation"> |
| 3349 | <section className="import-modal" role="dialog" aria-modal="true" aria-label={`Import ${provider.displayName} Portfolio`}> |
| 3350 | <header className="import-modal-header"><div><span>Step {importPreview ? "2" : "1"} of 2</span><h2>Import {provider.displayName} Portfolio</h2></div><button className="icon-button" aria-label="Close import" onClick={() => setImportProvider(null)}><X size={20} /></button></header> |
| 3351 | {provider.manualImportParserStatus !== "SUPPORTED" ? ( |
| 3352 | <p role="status">This broker portfolio file format is not yet supported. No columns have been guessed.</p> |
| 3353 | ) : ( |
| 3354 | <> |
| 3355 | <p className="import-section-label">Select statement</p> |
| 3356 | <label className="import-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => { |
| 3357 | event.preventDefault(); const selected = event.dataTransfer.files[0] ?? null; |
| 3358 | if (!selected || !selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024) { |
| 3359 | setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return; |
| 3360 | } |
| 3361 | setImportFile(selected); setImportPreview(null); setImportError(null); |
| 3362 | }}><UploadCloud size={32} /><strong>Drag & drop your CSV here</strong><span>or choose an ICICI/HDFC equity portfolio CSV</span><span className="button button-secondary">Choose CSV file</span><input type="file" accept=".csv,text/csv" onChange={(event) => { |
| 3363 | const selected = event.target.files?.[0] ?? null; |
| 3364 | if (selected && (!selected.name.toLowerCase().endsWith(".csv") || selected.size === 0 || selected.size > 5 * 1024 * 1024)) { |
| 3365 | setImportFile(null); setImportError("Choose a non-empty CSV file no larger than 5 MB."); return; |
| 3366 | } |
| 3367 | setImportFile(selected); setImportPreview(null); setImportError(null); |
| 3368 | }} /></label> |
| 3369 | {importFile ? <p className="import-file"><CheckCircle2 size={18} /> <strong>{importFile.name.replace(/[\\/]/g, "")}</strong></p> : null} |
| 3370 | {importFile && !detectedImportAccount(importFile.name, provider.brokerType) ? <label>Portfolio name<input value={importName} onChange={(event) => setImportName(event.target.value)} /></label> : importFile ? <p>Account detected: <strong>{detectedImportAccount(importFile.name, provider.brokerType)}</strong></p> : null} |
| 3371 | <Button variant="secondary" disabled={!importFile || importBusy} onClick={async () => { |
| 3372 | if (!importFile) return; |
| 3373 | setImportBusy(true); setImportError(null); |
| 3374 | try { setImportPreview(await portfolioApi.previewImport(provider.brokerType, importFile, importName)); } |
| 3375 | catch (error) { setImportError(getApiFailure(error).message); } |
| 3376 | finally { setImportBusy(false); } |
| 3377 | }}>{importBusy ? "Reading..." : "Preview portfolio"}</Button> |
| 3378 | {importError ? <p className="import-error" role="alert">{importError}</p> : null} |
| 3379 | {importPreview ? <div className="import-preview"> |
| 3380 | <p><strong>{importPreview.updatesExistingPortfolio ? `Update existing portfolio ${importPreview.portfolioName}` : `Portfolio: ${importPreview.portfolioName}`}</strong></p> |
| 3381 | <p>Broker: {provider.displayName} · Currency: {importPreview.currency}</p> |
| 3382 | {importPreview.statementAt ? <p>Statement date/time: {new Date(importPreview.statementAt).toLocaleString()}</p> : null} |
| 3383 | <p>Rows detected: {importPreview.rowsDetected} · Valid holdings: {importPreview.validHoldings} · Rejected rows: {importPreview.rejectedRows}</p> |
| 3384 | <p>Columns mapped: {importPreview.columnsMapped.join(", ")}</p> |
| 3385 | <div className="import-table-frame"><table className="import-table"><thead><tr><th>Company</th><th>Symbol</th><th>Quantity</th><th>Avg cost</th><th>Statement price</th><th>Market value</th><th>P&L</th></tr></thead><tbody>{importPreview.holdings.map((holding, index) => <tr key={`${holding.symbol}-${index}`}><td>{holding.companyName}</td><td>{holding.symbol}</td><td>{holding.quantity}</td><td>{formatMoney(holding.averageCost, importPreview.currency)}</td><td>{formatMoney(holding.importedPrice, importPreview.currency)}</td><td>{formatMoney(holding.marketValue, importPreview.currency)}</td><td>{formatMoney(holding.unrealizedPnl, importPreview.currency)}</td></tr>)}</tbody></table></div> |
| 3386 | {importPreview.issues.length ? <ul>{importPreview.issues.map((issue) => <li key={issue}>{issue}</li>)}</ul> : null} |
| 3387 | <Button disabled={importBusy || importPreview.validHoldings === 0} onClick={async () => { |
| 3388 | if (!importFile) return; |
| 3389 | setImportBusy(true); setImportError(null); |
| 3390 | try { |
| 3391 | const result = await portfolioApi.confirmImport(provider.brokerType, importFile, importName); |
| 3392 | await onImportComplete(result.portfolioId); |
| 3393 | setImportSuccess(true); |
| 3394 | window.setTimeout(() => setImportSuccess(false), 3500); |
| 3395 | setImportProvider(null); setImportFile(null); setImportPreview(null); setImportName(""); |
| 3396 | } catch (error) { setImportError(getApiFailure(error).message); } |
| 3397 | finally { setImportBusy(false); } |
| 3398 | }}>{importBusy ? "Importing..." : importPreview.updatesExistingPortfolio ? "Update Portfolio" : "Confirm Import"}</Button> |
| 3399 | </div> : null} |
| 3400 | </> |
| 3401 | )} |
| 3402 | </section></div> |
| 3403 | ) : null} |
| 3404 | </div> |
| 3405 | </Card> |
| 3406 | ); |
| 3407 | })} |
| 3408 | </div>{importSuccess ? <div className="success-toast" role="status"><CheckCircle2 size={18} /> Portfolio imported successfully</div> : null}</div> |
| 3409 | ); |
| 3410 | } |
| 3411 | |
| 3412 | function detectedImportAccount(filename: string, brokerType: string): string | null { |
| 3413 | const clean = filename.replace(/ \(\d+\)(?=\.csv$)/i, ""); |
| 3414 | const match = brokerType === "ICICI_DIRECT" ? clean.match(/^(\d+)_PortFolioEqtSummary\.csv$/i) |
| 3415 | : brokerType === "HDFC_SECURITIES" ? clean.match(/^Invest Right Equity Portfolio_(\d+)\.csv$/i) : null; |
| 3416 | return match?.[1] ?? null; |
| 3417 | } |
| 3418 | |
| 3419 | function legacyCompositeCompanyName(value?: string | null): string | null { |
| 3420 | const segments = (value ?? "").split("/").map((segment) => segment.trim()); |
| 3421 | return segments.length >= 3 && segments[2] ? segments[2] : null; |
| 3422 | } |
| 3423 | |
| 3424 | function researchCompanyName(position: PortfolioPosition, resolved?: PortfolioResearchCompany): string { |
| 3425 | const instrument = position.instrument; |
| 3426 | const customName = position.customDisplayName?.trim(); |
| 3427 | if (customName) return customName; |
| 3428 | const ticker = (resolved?.ticker ?? instrument.ticker).trim(); |
| 3429 | const structuredName = instrument.companyName?.trim(); |
| 3430 | if (structuredName && structuredName.toUpperCase() !== ticker.toUpperCase() |
| 3431 | && !legacyCompositeCompanyName(structuredName)) return structuredName; |
| 3432 | const trustedName = instrument.canonicalName?.trim() || resolved?.companyName?.trim(); |
| 3433 | if (trustedName && trustedName.toUpperCase() !== ticker.toUpperCase() |
| 3434 | && !legacyCompositeCompanyName(trustedName)) return trustedName; |
| 3435 | for (const candidate of [position.displayName, structuredName, trustedName, instrument.brokerDescription]) { |
| 3436 | const parsed = legacyCompositeCompanyName(candidate); |
| 3437 | if (parsed) return parsed; |
| 3438 | } |
| 3439 | return ticker || instrument.brokerSymbol?.trim() || "Unknown company"; |
| 3440 | } |
| 3441 | |
| 3442 | function ResearchView({ |
| 3443 | positions, |
| 3444 | portfolioResearchSummary, |
| 3445 | portfolioResearchLoading, |
| 3446 | portfolioResearchError, |
| 3447 | researchContext, |
| 3448 | watchlistResearch, |
| 3449 | watchlistResearchLoading, |
| 3450 | watchlistResearchError, |
| 3451 | selectedResearchInstrumentId, |
| 3452 | onSelectInstrument, |
| 3453 | summary, |
| 3454 | loading, |
| 3455 | eventType, |
| 3456 | impact, |
| 3457 | onEventType, |
| 3458 | onImpact, |
| 3459 | onRefresh, |
| 3460 | searchPresentation, |
| 3461 | onSearchSelect, |
| 3462 | searchSelectedMatch, |
| 3463 | searchWatchlistSaved, |
| 3464 | searchWatchlistBusy, |
| 3465 | searchWatchlistError, |
| 3466 | onToggleSearchWatchlist |
| 3467 | }: { |
| 3468 | positions: PortfolioPosition[]; |
| 3469 | portfolioResearchSummary: PortfolioResearchSummary | null; |
| 3470 | portfolioResearchLoading: boolean; |
| 3471 | portfolioResearchError: string | null; |
| 3472 | researchContext: ResearchContext; |
| 3473 | watchlistResearch: WatchlistResearchPresentation | null; |
| 3474 | watchlistResearchLoading: boolean; |
| 3475 | watchlistResearchError: string | null; |
| 3476 | selectedResearchInstrumentId: string; |
| 3477 | onSelectInstrument: (value: string) => void; |
| 3478 | summary: ResearchSummary | null; |
| 3479 | loading: boolean; |
| 3480 | eventType: string; |
| 3481 | impact: string; |
| 3482 | onEventType: (value: string) => void; |
| 3483 | onImpact: (value: string) => void; |
| 3484 | onRefresh: () => Promise<void>; |
| 3485 | searchPresentation: PortfolioResearchCompany | null; |
| 3486 | onSearchSelect: (match: ResearchInstrumentMatch) => void; |
| 3487 | searchSelectedMatch: ResearchInstrumentMatch | null; |
| 3488 | searchWatchlistSaved: boolean; |
| 3489 | searchWatchlistBusy: boolean; |
| 3490 | searchWatchlistError: string | null; |
| 3491 | onToggleSearchWatchlist: () => void; |
| 3492 | }) { |
| 3493 | const [openSection, setOpenSection] = useState<ResearchSectionId | null>(null); |
| 3494 | const [openEventId, setOpenEventId] = useState<string | null>(null); |
| 3495 | const [researchDetail, setResearchDetail] = useState<{ |
| 3496 | position?: PortfolioPosition; |
| 3497 | watchlistInstrumentId?: string; |
| 3498 | researchInstrumentId: string; |
| 3499 | } | null>(null); |
| 3500 | useEffect(() => { |
| 3501 | setResearchDetail(null); |
| 3502 | }, [researchContext.kind, selectedResearchInstrumentId]); |
| 3503 | const [watchlistDetailResearch, setWatchlistDetailResearch] = useState<{ |
| 3504 | instrumentId: string; |
| 3505 | research: PortfolioResearchCompany; |
| 3506 | } | null>(null); |
| 3507 | const [watchlistDetailError, setWatchlistDetailError] = useState<string | null>(null); |
| 3508 | const [watchlistDetailLoading, setWatchlistDetailLoading] = useState(false); |
| 3509 | const [watchlistDetailRetryKey, setWatchlistDetailRetryKey] = useState(0); |
| 3510 | const watchlistDetailFetchId = useRef(0); |
| 3511 | const detailRegion = |
| 3512 | researchContext.kind === "WATCHLIST" ? researchContext.region : undefined; |
| 3513 | useEffect(() => { |
| 3514 | const detail = researchDetail; |
| 3515 | const region = detailRegion; |
| 3516 | if (!detail || !detail.watchlistInstrumentId || !region) { |
| 3517 | setWatchlistDetailResearch(null); |
| 3518 | setWatchlistDetailError(null); |
| 3519 | setWatchlistDetailLoading(false); |
| 3520 | return; |
| 3521 | } |
| 3522 | const fetchId = ++watchlistDetailFetchId.current; |
| 3523 | const instrumentId = detail.researchInstrumentId; |
| 3524 | setWatchlistDetailLoading(true); |
| 3525 | setWatchlistDetailError(null); |
| 3526 | researchApi |
| 3527 | .getCompanyPresentation(instrumentId, region) |
| 3528 | .then((research) => { |
| 3529 | if (fetchId !== watchlistDetailFetchId.current) return; |
| 3530 | setWatchlistDetailResearch({ instrumentId, research }); |
| 3531 | setWatchlistDetailLoading(false); |
| 3532 | }) |
| 3533 | .catch((error) => { |
| 3534 | if (fetchId !== watchlistDetailFetchId.current) return; |
| 3535 | setWatchlistDetailError(getApiFailure(error).message); |
| 3536 | setWatchlistDetailLoading(false); |
| 3537 | }); |
| 3538 | }, [researchDetail?.watchlistInstrumentId, researchDetail?.researchInstrumentId, detailRegion, watchlistDetailRetryKey]); |
| 3539 | const filteredEvents = (summary?.recentEvents ?? []).filter((event) => { |
| 3540 | return (!eventType || event.eventType === eventType) && (!impact || event.impact === impact); |
| 3541 | }); |
| 3542 | const eventTypes = [...new Set((summary?.recentEvents ?? []).map((event) => event.eventType))].sort(); |
| 3543 | const impacts = [...new Set((summary?.recentEvents ?? []).map((event) => event.impact))].sort(); |
| 3544 | const documentsById = new Map((summary?.documents ?? []).map((document) => [document.documentId, document])); |
| 3545 | const researchSections = summary ? getResearchSections(summary, filteredEvents, eventType, impact) : []; |
| 3546 | const selectedPortfolioResearchCompany = |
| 3547 | portfolioResearchSummary?.companies.find((company) => company.instrumentId === selectedResearchInstrumentId) ?? null; |
| 3548 | const selectedWatchlistItem = watchlistResearch?.instruments.find( |
| 3549 | (item) => item.globalInstrumentId === selectedResearchInstrumentId |
| 3550 | ) ?? null; |
| 3551 | const selectedContextResearchCompany = researchContext.kind === "WATCHLIST" |
| 3552 | ? selectedWatchlistItem?.company ?? null |
| 3553 | : researchContext.kind === "SEARCH" ? searchPresentation : selectedPortfolioResearchCompany; |
| 3554 | const detailWatchlistItem = researchDetail?.watchlistInstrumentId |
| 3555 | ? watchlistResearch?.instruments.find((item) => item.globalInstrumentId === researchDetail.watchlistInstrumentId) |
| 3556 | : undefined; |
| 3557 | const portfolioDetailResearch = researchDetail |
| 3558 | ? portfolioResearchSummary?.companies.find( |
| 3559 | (company) => company.instrumentId === researchDetail.researchInstrumentId |
| 3560 | ) |
| 3561 | : undefined; |
| 3562 | const detailResearch: PortfolioResearchCompany | undefined = !researchDetail |
| 3563 | ? undefined |
| 3564 | : researchDetail.watchlistInstrumentId |
| 3565 | ? watchlistDetailResearch?.instrumentId === researchDetail.researchInstrumentId |
| 3566 | ? watchlistDetailResearch.research |
| 3567 | : undefined |
| 3568 | : portfolioDetailResearch |
| 3569 | ?? (researchContext.kind === "SEARCH" ? searchPresentation ?? undefined : undefined); |
| 3570 | const researchOptions = useMemo(() => { |
| 3571 | if (researchContext.kind === "WATCHLIST") { |
| 3572 | return (watchlistResearch?.instruments ?? []).map((item) => ({ |
| 3573 | value: item.globalInstrumentId, |
| 3574 | globalInstrumentId: item.globalInstrumentId, |
| 3575 | companyName: item.company.companyName, |
| 3576 | ticker: item.company.ticker, |
| 3577 | exchange: item.company.exchange, |
| 3578 | status: item.company.status, |
| 3579 | assetType: item.company.assetType, |
| 3580 | loadable: isResearchSummaryLoadable(item.company.status), |
| 3581 | held: false, |
| 3582 | quantity: 0, |
| 3583 | })); |
| 3584 | } |
| 3585 | const resolvedByHoldingKey = new Map<string, PortfolioResearchCompany>(); |
| 3586 | const tickerCounts = new Map<string, number>(); |
| 3587 | for (const company of portfolioResearchSummary?.companies ?? []) { |
| 3588 | const ticker = (company.ticker ?? "").toUpperCase(); |
| 3589 | if (ticker) { |
| 3590 | tickerCounts.set(ticker, (tickerCounts.get(ticker) ?? 0) + 1); |
| 3591 | } |
| 3592 | } |
| 3593 | for (const company of portfolioResearchSummary?.companies ?? []) { |
| 3594 | const ticker = (company.ticker ?? "").toUpperCase(); |
| 3595 | const keys = [ |
| 3596 | company.provider && company.providerInstrumentId ? `${company.provider}:${company.providerInstrumentId}` : "", |
| 3597 | company.isin ? `isin:${company.isin}` : "", |
| 3598 | `${company.ticker ?? ""}:${company.exchange ?? ""}`, |
| 3599 | ticker && tickerCounts.get(ticker) === 1 ? `ticker:${ticker}` : "" |
| 3600 | ].filter(Boolean); |
| 3601 | for (const key of keys) { |
| 3602 | resolvedByHoldingKey.set(key.toUpperCase(), company); |
| 3603 | } |
| 3604 | } |
| 3605 | |
| 3606 | const options = positions.map((position) => { |
| 3607 | const instrument = position.instrument; |
| 3608 | const globalInstrumentId = instrument.globalInstrumentId?.trim(); |
| 3609 | const keys = [ |
| 3610 | globalInstrumentId ? `global:${globalInstrumentId}` : "", |
| 3611 | instrument.provider && instrument.providerInstrumentId ? `${instrument.provider}:${instrument.providerInstrumentId}` : "", |
| 3612 | instrument.isin ? `isin:${instrument.isin}` : "", |
| 3613 | `${instrument.ticker}:${instrument.exchange}`, |
| 3614 | `ticker:${instrument.ticker}` |
| 3615 | ].filter(Boolean); |
| 3616 | const resolved = globalInstrumentId |
| 3617 | ? portfolioResearchSummary?.companies.find((company) => company.instrumentId === globalInstrumentId) |
| 3618 | : keys.map((key) => resolvedByHoldingKey.get(key.toUpperCase())).find(Boolean); |
| 3619 | const status = resolved?.status ?? (globalInstrumentId ? "GLOBAL_INSTRUMENT_RESOLVED" : "COMPANY_NOT_RESOLVED"); |
| 3620 | const displayTicker = resolved?.ticker ?? instrument.ticker; |
| 3621 | const displayExchange = resolved?.exchange ?? instrument.exchange; |
| 3622 | return { |
| 3623 | // The selected value is always the stable global research identity when |
| 3624 | // one is available; display/provider aliases are presentation metadata. |
| 3625 | value: globalInstrumentId ?? resolved?.instrumentId ?? instrument.instrumentId, |
| 3626 | globalInstrumentId, |
| 3627 | companyName: researchCompanyName(position, resolved), |
| 3628 | ticker: displayTicker, |
| 3629 | exchange: displayExchange, |
| 3630 | status, |
| 3631 | assetType: resolved?.assetType ?? instrument.assetType, |
| 3632 | loadable: Boolean(resolved?.instrumentId) && isResearchSummaryLoadable(status), |
| 3633 | held: true, |
| 3634 | quantity: position.quantity |
| 3635 | }; |
| 3636 | }); |
| 3637 | return options; |
| 3638 | }, [portfolioResearchSummary, positions, researchContext.kind, watchlistResearch]); |
| 3639 | const selectedResearchOption = researchContext.kind === "SEARCH" && searchSelectedMatch |
| 3640 | ? { value: searchSelectedMatch.globalInstrumentId, globalInstrumentId: searchSelectedMatch.globalInstrumentId, |
| 3641 | companyName: searchSelectedMatch.companyName, status: searchPresentation?.status, |
| 3642 | assetType: searchSelectedMatch.assetType, ticker: searchSelectedMatch.canonicalSymbol, |
| 3643 | exchange: searchSelectedMatch.exchange, held: false, quantity: 0 } |
| 3644 | : researchOptions.find((option) => option.value === selectedResearchInstrumentId); |
| 3645 | const refreshEligible = canRefreshResearchIdentity( |
| 3646 | selectedResearchOption?.globalInstrumentId ?? "", |
| 3647 | selectedContextResearchCompany?.status |
| 3648 | ?? (selectedContextResearchCompany ? undefined : selectedResearchOption?.status), |
| 3649 | selectedContextResearchCompany?.assetType |
| 3650 | ?? (selectedContextResearchCompany ? undefined : selectedResearchOption?.assetType), |
| 3651 | ); |
| 3652 | |
| 3653 | function toggleSection(sectionId: ResearchSectionId) { |
| 3654 | setOpenSection((current) => (current === sectionId ? null : sectionId)); |
| 3655 | } |
| 3656 | |
| 3657 | function toggleEvent(eventId: string) { |
| 3658 | setOpenEventId((current) => (current === eventId ? null : eventId)); |
| 3659 | } |
| 3660 | |
| 3661 | return ( |
| 3662 | <div className="research-layout"> |
| 3663 | <Card className="wide-panel"> |
| 3664 | <div className="panel-header"> |
| 3665 | <div> |
| 3666 | <h2>Company research</h2> |
| 3667 | <p>{researchContext.kind === "PORTFOLIO" |
| 3668 | ? "This research context contains actual portfolio holdings only." |
| 3669 | : researchContext.kind === "WATCHLIST" |
| 3670 | ? `${researchContext.name} · ${researchContext.region} · non-held public research` |
| 3671 | : researchContext.kind === "SEARCH" |
| 3672 | ? `Opening ${searchSelectedMatch?.companyName ?? searchPresentation?.companyName ?? selectedResearchInstrumentId} without attaching it to a portfolio.` |
| 3673 | : `Opening ${researchContext.name} without attaching it to a portfolio.`}</p> |
| 3674 | </div> |
| 3675 | </div> |
| 3676 | {researchContext.kind === "SEARCH" && searchSelectedMatch ? <section> |
| 3677 | <h3>{searchSelectedMatch.companyName}</h3> |
| 3678 | <p>Public company research · Not held</p> |
| 3679 | <p>{[searchSelectedMatch.canonicalSymbol ?? searchSelectedMatch.symbol, searchSelectedMatch.exchange, searchSelectedMatch.isin].filter(Boolean).join(" · ")}</p> |
| 3680 | <Button variant="secondary" disabled={!searchPresentation} onClick={() => setResearchDetail({ researchInstrumentId: selectedResearchInstrumentId })}>Open company research</Button> |
| 3681 | </section> : null} |
| 3682 | {researchContext.kind === "PORTFOLIO" && portfolioResearchError ? <p className="research-refresh-notice" role="alert">{portfolioResearchError}</p> : null} |
| 3683 | {watchlistResearchError ? <p className="research-refresh-notice" role="alert">{watchlistResearchError}</p> : null} |
| 3684 | {researchContext.kind === "WATCHLIST_PENDING" || watchlistResearchLoading ? <Skeleton rows={3} /> : null} |
| 3685 | {(researchContext.kind === "PORTFOLIO" && portfolioResearchSummary) |
| 3686 | || (researchContext.kind === "WATCHLIST" && watchlistResearch?.instruments.length) ? ( |
| 3687 | <div className="portfolio-research-table" role="table" aria-label="Company research summary"> |
| 3688 | <div className="portfolio-research-row portfolio-research-head" role="row"> |
| 3689 | <span role="columnheader">Company</span> |
| 3690 | <span role="columnheader">Price / P/E</span> |
| 3691 | <span role="columnheader">Valuation</span> |
| 3692 | <span role="columnheader">Latest result</span> |
| 3693 | <span role="columnheader">Ownership</span> |
| 3694 | <span role="columnheader">Catalyst</span> |
| 3695 | <span role="columnheader">Sources</span> |
| 3696 | <span role="columnheader">Status</span> |
| 3697 | </div> |
| 3698 | {researchContext.kind === "WATCHLIST" ? (watchlistResearch?.instruments ?? []).map((item) => { |
| 3699 | const company = item.company; |
| 3700 | const companyStatus = company.status ?? ""; |
| 3701 | |
| 3702 | const watchlistPrice = |
| 3703 | item.marketData?.quote?.last?.amount |
| 3704 | ?? item.marketData?.snapshot?.price |
| 3705 | ?? null; |
| 3706 | |
| 3707 | const watchlistPe = |
| 3708 | item.marketData?.snapshot?.peRatio |
| 3709 | ?? null; |
| 3710 | |
| 3711 | const watchlistCurrency = |
| 3712 | item.marketData?.quote?.currency |
| 3713 | ?? item.marketData?.snapshot?.currency |
| 3714 | ?? item.currency |
| 3715 | ?? undefined; |
| 3716 | |
| 3717 | const valuationState = company.valuation?.state; |
| 3718 | const valuationReason = company.valuation?.reason; |
| 3719 | |
| 3720 | const ownershipIncreases = company.ownershipIncreases ?? []; |
| 3721 | const shareholdingChanges = company.shareholdingChanges ?? []; |
| 3722 | const catalysts = company.currentQuarterCatalysts ?? []; |
| 3723 | |
| 3724 | const sourceCount = company.sourceCount; |
| 3725 | const documentCount = company.documentCount; |
| 3726 | |
| 3727 | const performanceTone = performanceRowTone(item.sourcePerformancePct); |
| 3728 | return ( |
| 3729 | <button |
| 3730 | className={`portfolio-research-row portfolio-research-company market-intelligence-research-row market-intelligence-research-row-${performanceTone}`} |
| 3731 | key={`watchlist-${item.globalInstrumentId}`} |
| 3732 | role="row" |
| 3733 | type="button" |
| 3734 | data-held="false" |
| 3735 | data-region={researchContext.region} |
| 3736 | data-global-instrument-id={item.globalInstrumentId} |
| 3737 | aria-current={selectedResearchInstrumentId === item.globalInstrumentId ? "true" : undefined} |
| 3738 | aria-label={`${company.companyName}, public research${item.sourcePeriod ? `, market return ${formatSignedPerformancePct(item.sourcePerformancePct)}` : ""}`} |
| 3739 | onClick={() => { |
| 3740 | onSelectInstrument(item.globalInstrumentId); |
| 3741 | setResearchDetail({ |
| 3742 | watchlistInstrumentId: item.globalInstrumentId, |
| 3743 | researchInstrumentId: company.instrumentId ?? item.globalInstrumentId, |
| 3744 | }); |
| 3745 | }} |
| 3746 | > |
| 3747 | <span role="cell"> |
| 3748 | <strong>{company.companyName} <span className={`research-status-dot research-status-${researchStatusTone(companyStatus)}`} role="img" aria-label={researchStatusDescription(companyStatus)} title={researchStatusDescription(companyStatus)} /></strong> |
| 3749 | <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small> |
| 3750 | <small>{researchContext.name} · Public company research · Not held</small> |
| 3751 | </span> |
| 3752 | <span role="cell"> |
| 3753 | {watchlistPrice == null |
| 3754 | ? "N/A" |
| 3755 | : formatMoney(Number(watchlistPrice), watchlistCurrency)} |
| 3756 | <small> |
| 3757 | {watchlistPe == null |
| 3758 | ? "P/E N/A" |
| 3759 | : `P/E ${new Intl.NumberFormat("en-IN", { |
| 3760 | maximumFractionDigits: 2 |
| 3761 | }).format(Number(watchlistPe))}`} |
| 3762 | </small> |
| 3763 | </span> |
| 3764 | <span role="cell"> |
| 3765 | <Badge tone={valuationTone(valuationState)}>{valuationState ?? "N/A"}</Badge> |
| 3766 | <small>{valuationReason ?? ""}</small> |
| 3767 | </span> |
| 3768 | <span role="cell">Not publicly available</span> |
| 3769 | <span role="cell"> |
| 3770 | {ownershipIncreases.length |
| 3771 | ? ownershipIncreases.map((value) => value === "FII_FPI" ? "FII/FPI" : value.replaceAll("_", " ")).join(" · ") |
| 3772 | : "Unavailable"} |
| 3773 | <small>{shareholdingChanges.length ? `${shareholdingChanges.length} comparable trends` : "Previous comparable period not found"}</small> |
| 3774 | </span> |
| 3775 | <span role="cell"> |
| 3776 | {catalysts.length ? `${catalysts.length} current` : company.catalystScore ?? "None verified"} |
| 3777 | </span> |
| 3778 | <span role="cell"> |
| 3779 | {sourceCount == null && documentCount == null |
| 3780 | ? "N/A" |
| 3781 | : `${sourceCount ?? 0} sources / ${documentCount ?? 0} docs`} |
| 3782 | </span> |
| 3783 | <span role="cell"> |
| 3784 | <Badge tone={researchStatusTone(companyStatus)}>{companyStatus.replaceAll("_", " ")}</Badge> |
| 3785 | {item.sourcePeriod ? <strong className={`market-intelligence-table-return ${performanceTone}-text`}>Market return ({item.sourcePeriod}) {formatSignedPerformancePct(item.sourcePerformancePct)}</strong> : null} |
| 3786 | </span> |
| 3787 | </button> |
| 3788 | ); |
| 3789 | }) : null} |
| 3790 | {researchContext.kind === "PORTFOLIO" ? (portfolioResearchSummary?.companies ?? []).map((company) => { |
| 3791 | const holding = positions.find((position) => position.instrument.globalInstrumentId === company.instrumentId |
| 3792 | || position.instrument.instrumentId === company.instrumentId |
| 3793 | || Boolean(position.instrument.isin && position.instrument.isin === company.isin) |
| 3794 | || (position.instrument.ticker === company.ticker && position.instrument.exchange === company.exchange)); |
| 3795 | return ( |
| 3796 | <button |
| 3797 | className="portfolio-research-row portfolio-research-company" |
| 3798 | key={`${company.instrumentId ?? company.companyName}-${company.status}`} |
| 3799 | role="row" |
| 3800 | type="button" |
| 3801 | onClick={() => { |
| 3802 | if (company.instrumentId) { |
| 3803 | onSelectInstrument(company.instrumentId); |
| 3804 | } |
| 3805 | if (holding && company.assetType === "EQUITY" && company.instrumentId) { |
| 3806 | setResearchDetail({ position: holding, researchInstrumentId: company.instrumentId }); |
| 3807 | } |
| 3808 | }} |
| 3809 | > |
| 3810 | <span role="cell"> |
| 3811 | <strong>{company.companyName} <span className={`research-status-dot research-status-${researchStatusTone(company.status)}`} role="img" aria-label={researchStatusDescription(company.status)} title={researchStatusDescription(company.status)} /></strong> |
| 3812 | <small>{[company.ticker, company.exchange, company.isin].filter(Boolean).join(" / ")}</small> |
| 3813 | {holding ? <small>Held · Qty {holding.quantity.toLocaleString("en")}</small> : null} |
| 3814 | </span> |
| 3815 | <span role="cell">{metricText(company.valuation.currentPe)}</span> |
| 3816 | <span role="cell"><Badge tone={valuationTone(company.valuation.state)}>{company.valuation.state}</Badge><small>{company.valuation.reason}</small></span> |
| 3817 | <span role="cell">{latestResultText(company)}</span> |
| 3818 | <span role="cell">{company.ownershipIncreases.length ? company.ownershipIncreases.map((value) => value === "FII_FPI" ? "FII/FPI" : value.replaceAll("_", " ")).join(" · ") : "Unavailable"}<small>{company.shareholdingChanges.length ? `${company.shareholdingChanges.length} comparable trends` : "Previous comparable period not found"}</small></span> |
| 3819 | <span role="cell">{company.currentQuarterCatalysts.length ? `${company.currentQuarterCatalysts.length} current` : company.catalystScore ?? "None verified"}</span> |
| 3820 | <span role="cell"> |
| 3821 | {company.sourceCount} sources / {company.documentCount} docs |
| 3822 | </span> |
| 3823 | <span role="cell"> |
| 3824 | <Badge tone={researchStatusTone(company.status)}>{company.status.replaceAll("_", " ")}</Badge> |
| 3825 | <small>{company.lastRefresh ? new Date(company.lastRefresh).toLocaleString() : company.mode}</small> |
| 3826 | </span> |
| 3827 | </button> |
| 3828 | ); |
| 3829 | }) : null} |
| 3830 | </div> |
| 3831 | ) : summary || researchContext.kind === "SEARCH" ? null : ( |
| 3832 | <EmptyState |
| 3833 | title={researchContext.kind === "PORTFOLIO" ? "No company research available" : "No watchlist instruments"} |
| 3834 | message={researchContext.kind === "PORTFOLIO" |
| 3835 | ? "Select a company and open Research readiness to find only the data it needs." |
| 3836 | : "Choose a Market Intelligence stock to add it to this regional watchlist."} |
| 3837 | /> |
| 3838 | )} |
| 3839 | {researchDetail && researchDetail.watchlistInstrumentId |
| 3840 | ? watchlistDetailLoading |
| 3841 | ? <> |
| 3842 | <Skeleton rows={4} /> |
| 3843 | <p>Loading public research...</p> |
| 3844 | </> |
| 3845 | : watchlistDetailError |
| 3846 | ? <ErrorState |
| 3847 | message={watchlistDetailError} |
| 3848 | action={<Button variant="secondary" onClick={() => setWatchlistDetailRetryKey((key) => key + 1)}>Retry</Button>} |
| 3849 | /> |
| 3850 | : detailResearch |
| 3851 | ? <StockResearchDrawer position={researchDetail.position} watchlistItem={detailWatchlistItem} research={detailResearch} onClose={() => setResearchDetail(null)} /> |
| 3852 | : null |
| 3853 | : researchDetail && detailResearch |
| 3854 | ? <StockResearchDrawer position={researchDetail.position} watchlistItem={detailWatchlistItem} research={detailResearch} onClose={() => setResearchDetail(null)} /> |
| 3855 | : null} |
| 3856 | </Card> |
| 3857 | |
| 3858 | <Card className="wide-panel research-sticky-panel"> |
| 3859 | <div className="panel-header"> |
| 3860 | <div> |
| 3861 | <h2>Research intelligence</h2> |
| 3862 | <p>Structured evidence, catalyst scoring, and source citations.</p> |
| 3863 | </div> |
| 3864 | <div className="research-actions"> |
| 3865 | {summary?.demo ? <Badge tone="info">DEMO</Badge> : null} |
| 3866 | {summary && !summary.demo ? <Badge tone="positive">LIVE</Badge> : null} |
| 3867 | <Button |
| 3868 | variant="secondary" |
| 3869 | onClick={onRefresh} |
| 3870 | disabled={loading || !refreshEligible} |
| 3871 | > |
| 3872 | <RefreshCw size={16} /> |
| 3873 | Research readiness |
| 3874 | </Button> |
| 3875 | </div> |
| 3876 | </div> |
| 3877 | <div className="research-controls"> |
| 3878 | {researchContext.kind === "SEARCH" && searchSelectedMatch && selectedResearchInstrumentId ? ( |
| 3879 | <small className="research-search-selected"> |
| 3880 | <span className="research-search-identity" style={{ display: "flex", flexDirection: "column" }}> |
| 3881 | <span className="research-search-name">{searchPresentation?.companyName ?? searchSelectedMatch?.companyName ?? selectedResearchInstrumentId}</span> |
| 3882 | <span className="research-search-symbol">{[searchSelectedMatch?.canonicalSymbol ?? searchSelectedMatch?.symbol, searchSelectedMatch?.exchange, searchSelectedMatch?.isin].filter(Boolean).join(" · ") || selectedResearchInstrumentId}</span> |
| 3883 | <span className="research-search-status">Public company research · Not held</span> |
| 3884 | </span> |
| 3885 | <Button variant="secondary" disabled={searchWatchlistBusy || searchWatchlistSaved} onClick={() => onToggleSearchWatchlist()}> |
| 3886 | {searchWatchlistSaved ? `Saved to ${regionalWatchlistName(searchSelectedMatch.region)}` : `Add to ${regionalWatchlistName(searchSelectedMatch.region)}`} |
| 3887 | </Button> |
| 3888 | {searchWatchlistError ? <small role="alert">{searchWatchlistError}</small> : null} |
| 3889 | </small> |
| 3890 | ) : null} |
| 3891 | <div className="research-controls-filters" style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center", flexBasis: "100%" }}> |
| 3892 | {researchContext.kind !== "SEARCH" ? ( |
| 3893 | <label className="sort-control research-company-control"> |
| 3894 | <Search size={16} aria-hidden="true" /> |
| 3895 | <span>Company</span> |
| 3896 | <select |
| 3897 | className="research-company-select" |
| 3898 | value={selectedResearchInstrumentId} |
| 3899 | title={selectedResearchOption?.companyName} |
| 3900 | onChange={(event) => onSelectInstrument(event.target.value)} |
| 3901 | > |
| 3902 | {researchOptions.map((option) => ( |
| 3903 | <option key={`${option.value}-${option.companyName}`} value={option.value} title={option.companyName}> |
| 3904 | {option.companyName} |
| 3905 | </option> |
| 3906 | ))} |
| 3907 | </select> |
| 3908 | {selectedResearchOption ? ( |
| 3909 | <small className="research-company-meta"> |
| 3910 | {[selectedResearchOption.ticker, selectedResearchOption.exchange, |
| 3911 | selectedResearchOption.held |
| 3912 | ? `Held · quantity ${selectedResearchOption.quantity.toLocaleString("en")}` |
| 3913 | : "Public company research · Not held"].filter(Boolean).join(" · ")} |
| 3914 | </small> |
| 3915 | ) : null} |
| 3916 | </label> |
| 3917 | ) : null} |
| 3918 | <label className="sort-control"> |
| 3919 | <span>Event</span> |
| 3920 | <select value={eventType} onChange={(event) => onEventType(event.target.value)}> |
| 3921 | <option value="">All events</option> |
| 3922 | {eventTypes.map((value) => ( |
| 3923 | <option key={value} value={value}> |
| 3924 | {value.replaceAll("_", " ")} |
| 3925 | </option> |
| 3926 | ))} |
| 3927 | </select> |
| 3928 | </label> |
| 3929 | <label className="sort-control"> |
| 3930 | <span>Impact</span> |
| 3931 | <select value={impact} onChange={(event) => onImpact(event.target.value)}> |
| 3932 | <option value="">All impacts</option> |
| 3933 | {impacts.map((value) => ( |
| 3934 | <option key={value} value={value}> |
| 3935 | {value.replaceAll("_", " ")} |
| 3936 | </option> |
| 3937 | ))} |
| 3938 | </select> |
| 3939 | </label> |
| 3940 | </div> |
| 3941 | </div> |
| 3942 | </Card> |
| 3943 | |
| 3944 | {loading ? <Skeleton rows={5} /> : null} |
| 3945 | |
| 3946 | {!loading && summary ? ( |
| 3947 | <> |
| 3948 | <section className="metrics-grid" aria-label="Research scores"> |
| 3949 | <MetricCard label="Catalyst score" value={String(summary.catalystScore.overallScore)} meta="0-100 deterministic" /> |
| 3950 | <MetricCard label="Research confidence" value={`${summary.catalystScore.researchConfidence}%`} /> |
| 3951 | <MetricCard label="Recent events" value={String(summary.recentEvents.length)} /> |
| 3952 | <MetricCard label="Documents" value={String(summary.documents.length)} /> |
| 3953 | <MetricCard label="Freshness" value={summary.dataFreshness} tone={summary.demo ? "warning" : "neutral"} /> |
| 3954 | <MetricCard label="Last refresh" value={summary.lastRefreshAt ? new Date(summary.lastRefreshAt).toLocaleDateString() : "Not refreshed"} /> |
| 3955 | </section> |
| 3956 | |
| 3957 | <Card className="wide-panel"> |
| 3958 | <div className="panel-header"> |
| 3959 | <div> |
| 3960 | <h2>{summary.profile.companyName}</h2> |
| 3961 | <p> |
| 3962 | {summary.profile.ticker} / {summary.profile.exchange} / {summary.profile.country} / {summary.profile.isin ?? "No ISIN"} |
| 3963 | </p> |
| 3964 | </div> |
| 3965 | </div> |
| 3966 | <section className="research-accordion" aria-label="Research detail sections"> |
| 3967 | {researchSections.map((section) => { |
| 3968 | const isOpen = openSection === section.id; |
| 3969 | const panelId = `research-section-${section.id}`; |
| 3970 | const buttonId = `research-section-button-${section.id}`; |
| 3971 | |
| 3972 | return ( |
| 3973 | <article className="research-accordion-item" key={section.id}> |
| 3974 | <h3> |
| 3975 | <button |
| 3976 | id={buttonId} |
| 3977 | className="research-accordion-button" |
| 3978 | type="button" |
| 3979 | aria-expanded={isOpen} |
| 3980 | aria-controls={panelId} |
| 3981 | onClick={() => toggleSection(section.id)} |
| 3982 | > |
| 3983 | <span>{section.title}</span> |
| 3984 | <span className="research-section-meta">{section.metricLabel}</span> |
| 3985 | <ChevronDown className="accordion-chevron" size={18} aria-hidden="true" /> |
| 3986 | </button> |
| 3987 | </h3> |
| 3988 | <div |
| 3989 | id={panelId} |
| 3990 | className="research-accordion-panel" |
| 3991 | role="region" |
| 3992 | aria-labelledby={buttonId} |
| 3993 | hidden={!isOpen} |
| 3994 | > |
| 3995 | {section.id === "overview" ? ( |
| 3996 | <ResearchOverview summary={summary} /> |
| 3997 | ) : section.id === "news" ? ( |
| 3998 | <ResearchEventRows |
| 3999 | events={section.events} |
| 4000 | documentsById={documentsById} |
| 4001 | openEventId={openEventId} |
| 4002 | onToggleEvent={toggleEvent} |
| 4003 | /> |
| 4004 | ) : section.id === "sources" ? ( |
| 4005 | <ResearchSources documents={summary.documents} /> |
| 4006 | ) : section.events.length > 0 ? ( |
| 4007 | <div className="research-detail-list"> |
| 4008 | {section.events.map((event) => ( |
| 4009 | <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} key={event.eventId} /> |
| 4010 | ))} |
| 4011 | </div> |
| 4012 | ) : ( |
| 4013 | <EmptyState title="No evidence in this section" message="No filtered research events currently map to this topic." /> |
| 4014 | )} |
| 4015 | </div> |
| 4016 | </article> |
| 4017 | ); |
| 4018 | })} |
| 4019 | </section> |
| 4020 | </Card> |
| 4021 | </> |
| 4022 | ) : null} |
| 4023 | |
| 4024 | {!loading && !summary ? ( |
| 4025 | <Card className="wide-panel"> |
| 4026 | <EmptyState |
| 4027 | title={researchEmptyTitle(selectedContextResearchCompany?.status)} |
| 4028 | message={researchContext.kind === "SEARCH" ? "Open Research readiness to check the data available for this company." : researchEmptyMessage(selectedContextResearchCompany?.status)} |
| 4029 | /> |
| 4030 | </Card> |
| 4031 | ) : null} |
| 4032 | </div> |
| 4033 | ); |
| 4034 | } |
| 4035 | |
| 4036 | function ResearchOverview({ summary }: { summary: ResearchSummary }) { |
| 4037 | const categoryRows = summary.catalystScore.categoryEvidence |
| 4038 | ? Object.entries(summary.catalystScore.categoryEvidence).map(([label, evidence]) => [label, evidence.score, evidence.status] as const) |
| 4039 | : Object.entries(summary.catalystScore.buckets).map(([label, value]) => [label, value, value === null ? "NO_EVIDENCE" : "NEUTRAL_EVIDENCE"] as const); |
| 4040 | |
| 4041 | return ( |
| 4042 | <div className="research-overview-grid"> |
| 4043 | {categoryRows.map(([label, value, status]) => ( |
| 4044 | <div className="score-row compact" key={label}> |
| 4045 | <span>{researchCategoryLabel(label)}</span> |
| 4046 | <strong>{value ?? "N/A"}</strong> |
| 4047 | <div className="bar-track" aria-hidden="true"> |
| 4048 | <span style={{ width: `${value ?? 0}%` }} /> |
| 4049 | </div> |
| 4050 | <small>{status.replaceAll("_", " ")}</small> |
| 4051 | </div> |
| 4052 | ))} |
| 4053 | <div className="research-summary-note"> |
| 4054 | <strong>{summary.demo ? "DEMO research data" : "Live research data"}</strong> |
| 4055 | <span> |
| 4056 | {summary.recentEvents.length} events / {summary.documents.length} documents / generated{" "} |
| 4057 | {new Date(summary.catalystScore.generatedAt).toLocaleDateString()} |
| 4058 | </span> |
| 4059 | </div> |
| 4060 | </div> |
| 4061 | ); |
| 4062 | } |
| 4063 | |
| 4064 | function researchCategoryLabel(category: string) { |
| 4065 | return ({ |
| 4066 | GROWTH: "Growth", |
| 4067 | ORDERS_BACKLOG: "Orders & Backlog", |
| 4068 | CAPEX: "CAPEX & Capacity", |
| 4069 | CLIENTS: "Customers", |
| 4070 | GUIDANCE: "Guidance", |
| 4071 | } as Record<string, string>)[category] ?? category.replaceAll("_", " "); |
| 4072 | } |
| 4073 | |
| 4074 | function ResearchEventRows({ |
| 4075 | events, |
| 4076 | documentsById, |
| 4077 | openEventId, |
| 4078 | onToggleEvent |
| 4079 | }: { |
| 4080 | events: ResearchEvent[]; |
| 4081 | documentsById: Map<string, ResearchDocument>; |
| 4082 | openEventId: string | null; |
| 4083 | onToggleEvent: (eventId: string) => void; |
| 4084 | }) { |
| 4085 | if (events.length === 0) { |
| 4086 | return <EmptyState title="No matching events" message="Change the filters or refresh research fixtures." />; |
| 4087 | } |
| 4088 | |
| 4089 | return ( |
| 4090 | <div className="research-event-rows"> |
| 4091 | {events.map((event) => { |
| 4092 | const isOpen = openEventId === event.eventId; |
| 4093 | const panelId = `research-event-${event.eventId}`; |
| 4094 | const buttonId = `research-event-button-${event.eventId}`; |
| 4095 | |
| 4096 | return ( |
| 4097 | <article className="research-event-row" key={event.eventId}> |
| 4098 | <h4> |
| 4099 | <button |
| 4100 | id={buttonId} |
| 4101 | className="research-event-row-button" |
| 4102 | type="button" |
| 4103 | aria-expanded={isOpen} |
| 4104 | aria-controls={panelId} |
| 4105 | onClick={() => onToggleEvent(event.eventId)} |
| 4106 | > |
| 4107 | <span> |
| 4108 | <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge> |
| 4109 | <strong>{event.title}</strong> |
| 4110 | </span> |
| 4111 | <span>{event.eventType.replaceAll("_", " ")}</span> |
| 4112 | <ChevronDown className="accordion-chevron" size={16} aria-hidden="true" /> |
| 4113 | </button> |
| 4114 | </h4> |
| 4115 | <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}> |
| 4116 | <ResearchEventDetail event={event} document={documentsById.get(event.sourceDocumentId)} /> |
| 4117 | </div> |
| 4118 | </article> |
| 4119 | ); |
| 4120 | })} |
| 4121 | </div> |
| 4122 | ); |
| 4123 | } |
| 4124 | |
| 4125 | function ResearchEventDetail({ event, document }: { event: ResearchEvent; document?: ResearchDocument }) { |
| 4126 | const supportingSources = event.supportingSources?.length |
| 4127 | ? event.supportingSources |
| 4128 | : [ |
| 4129 | { |
| 4130 | publisher: document?.publisher ?? document?.sourceName ?? event.sourceType, |
| 4131 | url: event.sourceUrl, |
| 4132 | sourceType: event.sourceClassification ?? document?.sourceClassification ?? event.sourceType, |
| 4133 | publishedAt: event.publishedAt ?? event.eventDate ?? document?.publishedAt ?? null, |
| 4134 | retrievedAt: event.retrievedAt ?? document?.retrievedAt ?? event.detectedAt, |
| 4135 | reliability: event.reliability, |
| 4136 | sourceMode: event.sourceMode, |
| 4137 | documentId: event.sourceDocumentId, |
| 4138 | sourceName: document?.sourceName ?? event.sourceType, |
| 4139 | canonicalUrl: event.sourceUrl, |
| 4140 | independent: true |
| 4141 | } |
| 4142 | ]; |
| 4143 | return ( |
| 4144 | <div className="research-event-detail"> |
| 4145 | <div className="panel-header compact"> |
| 4146 | <div> |
| 4147 | <Badge tone="neutral">{event.eventType.replaceAll("_", " ")}</Badge> |
| 4148 | <h4>{event.title}</h4> |
| 4149 | </div> |
| 4150 | <Badge tone={impactTone(event.impact)}>{event.impact.replaceAll("_", " ")}</Badge> |
| 4151 | </div> |
| 4152 | <div className="event-value-line"> |
| 4153 | <strong>{event.monetaryOriginal ?? (event.capacityValue ? `${event.capacityValue} ${event.capacityUnit}` : event.percentageOriginal ?? "Value undisclosed")}</strong> |
| 4154 | <span>{event.customer ?? event.counterparty ?? event.location ?? event.timeHorizon.replaceAll("_", " ")}</span> |
| 4155 | </div> |
| 4156 | <p>{event.summary}</p> |
| 4157 | <dl className="event-facts"> |
| 4158 | <div> |
| 4159 | <dt>Confidence</dt> |
| 4160 | <dd>{Math.round(event.confidence * 100)}%</dd> |
| 4161 | </div> |
| 4162 | <div> |
| 4163 | <dt>Reliability</dt> |
| 4164 | <dd>{event.reliability}</dd> |
| 4165 | </div> |
| 4166 | <div> |
| 4167 | <dt>Published</dt> |
| 4168 | <dd>{event.eventDate ? new Date(event.eventDate).toLocaleDateString() : "Unknown"}</dd> |
| 4169 | </div> |
| 4170 | <div> |
| 4171 | <dt>Source</dt> |
| 4172 | <dd>{document?.sourceName ?? event.sourceClassification ?? event.sourceType}</dd> |
| 4173 | </div> |
| 4174 | <div> |
| 4175 | <dt>Horizon</dt> |
| 4176 | <dd>{event.timeHorizon.replaceAll("_", " ")}</dd> |
| 4177 | </div> |
| 4178 | </dl> |
| 4179 | <blockquote>{event.rawEvidenceReference}</blockquote> |
| 4180 | <div className="supporting-sources"> |
| 4181 | <strong>Supporting sources</strong> |
| 4182 | {supportingSources.map((source) => ( |
| 4183 | <a className="source-link" href={source.url} target="_blank" rel="noreferrer" key={`${event.eventId}-${source.documentId}`}> |
| 4184 | <span>{source.publisher ?? source.sourceName}</span> |
| 4185 | <small> |
| 4186 | {source.sourceMode} / {source.sourceType.replaceAll("_", " ")} / {source.reliability} /{" "} |
| 4187 | {source.publishedAt ? new Date(source.publishedAt).toLocaleDateString() : "No publication date"} /{" "} |
| 4188 | {source.independent ? "Independent" : "Duplicate"} |
| 4189 | </small> |
| 4190 | </a> |
| 4191 | ))} |
| 4192 | </div> |
| 4193 | </div> |
| 4194 | ); |
| 4195 | } |
| 4196 | |
| 4197 | function ResearchSources({ documents }: { documents: ResearchDocument[] }) { |
| 4198 | if (documents.length === 0) { |
| 4199 | return <EmptyState title="No sources" message="No source documents are available for this research profile." />; |
| 4200 | } |
| 4201 | |
| 4202 | return ( |
| 4203 | <div className="source-list"> |
| 4204 | {documents.map((document) => ( |
| 4205 | <a href={document.canonicalUrl} target="_blank" rel="noreferrer" key={document.documentId}> |
| 4206 | <strong>{document.title ?? document.publisher ?? document.sourceName}</strong> |
| 4207 | <span> |
| 4208 | {document.sourceMode} / {(document.sourceClassification ?? document.sourceType).replaceAll("_", " ")} / {document.reliabilityLevel} /{" "} |
| 4209 | {document.publishedAt ? new Date(document.publishedAt).toLocaleDateString() : "No publication date"} |
| 4210 | </span> |
| 4211 | <small> |
| 4212 | {document.sourceName} / {document.status} / retrieved {new Date(document.retrievedAt).toLocaleDateString()} / hash{" "} |
| 4213 | {document.contentHash.slice(0, 12)} |
| 4214 | </small> |
| 4215 | </a> |
| 4216 | ))} |
| 4217 | </div> |
| 4218 | ); |
| 4219 | } |
| 4220 | |
| 4221 | function getResearchSections(summary: ResearchSummary, filteredEvents: ResearchEvent[], eventType: string, impact: string) { |
| 4222 | const growth = categorySupportingEvents(summary, "GROWTH", filteredEvents, eventType, impact, ["GEOGRAPHIC_EXPANSION", "PARTNERSHIP", "PRODUCT_LAUNCH"]); |
| 4223 | const orders = categorySupportingEvents(summary, "ORDERS_BACKLOG", filteredEvents, eventType, impact, ["NEW_ORDER", "ORDER_BACKLOG_CHANGE", "MAJOR_CONTRACT", "GOVERNMENT_CONTRACT"]); |
| 4224 | const capex = categorySupportingEvents(summary, "CAPEX", filteredEvents, eventType, impact, ["CAPEX", "CAPACITY_EXPANSION", "NEW_FACILITY", "FACTORY_EXPANSION", "PROJECT_DELAY"]); |
| 4225 | const customers = categorySupportingEvents(summary, "CLIENTS", filteredEvents, eventType, impact, ["NEW_CUSTOMER", "CUSTOMER_EXPANSION", "MAJOR_CUSTOMER", "CUSTOMER_LOSS"]); |
| 4226 | const guidance = categorySupportingEvents(summary, "GUIDANCE", filteredEvents, eventType, impact, ["GUIDANCE_RAISED", "GUIDANCE_LOWERED", "GUIDANCE_CUT", "GUIDANCE_MAINTAINED", "REVENUE_GUIDANCE", "MARGIN_GUIDANCE"]); |
| 4227 | |
| 4228 | return [ |
| 4229 | { id: "overview" as const, title: "Overview", metricLabel: `Score ${summary.catalystScore.overallScore}`, events: filteredEvents }, |
| 4230 | { id: "growth" as const, title: "Growth", metricLabel: categoryMetricLabel(summary, "GROWTH"), events: growth }, |
| 4231 | { id: "orders" as const, title: "Orders & Backlog", metricLabel: categoryMetricLabel(summary, "ORDERS_BACKLOG"), events: orders }, |
| 4232 | { id: "capex" as const, title: "CAPEX & Capacity", metricLabel: categoryMetricLabel(summary, "CAPEX"), events: capex }, |
| 4233 | { id: "customers" as const, title: "Customers", metricLabel: categoryMetricLabel(summary, "CLIENTS"), events: customers }, |
| 4234 | { id: "guidance" as const, title: "Guidance", metricLabel: categoryMetricLabel(summary, "GUIDANCE"), events: guidance }, |
| 4235 | { id: "news" as const, title: "News / Events", metricLabel: `Events ${filteredEvents.length}`, events: filteredEvents }, |
| 4236 | { id: "sources" as const, title: "Sources", metricLabel: `Documents ${summary.documents.length}`, events: [] } |
| 4237 | ]; |
| 4238 | } |
| 4239 | |
| 4240 | function categorySupportingEvents(summary: ResearchSummary, category: string, filteredRecentEvents: ResearchEvent[], eventType: string, impact: string, fallbackTypes: string[]) { |
| 4241 | const evidence = summary.catalystScore.categoryEvidence?.[category]; |
| 4242 | if (evidence?.supportingEvents) { |
| 4243 | return evidence.supportingEvents.filter((event) => |
| 4244 | (!eventType || event.eventType === eventType) && (!impact || event.impact === impact) |
| 4245 | ); |
| 4246 | } |
| 4247 | return eventsMatching(filteredRecentEvents, fallbackTypes); |
| 4248 | } |
| 4249 | |
| 4250 | function categoryMetricLabel(summary: ResearchSummary, category: string) { |
| 4251 | const evidence = summary.catalystScore.categoryEvidence?.[category]; |
| 4252 | if (evidence?.status === "NO_EVIDENCE" || evidence?.score == null) { |
| 4253 | return "N/A / No evidence"; |
| 4254 | } |
| 4255 | const count = evidence.independentSourceCount ?? evidence.sourceCount; |
| 4256 | const sources = count === 1 ? "1 source" : `${count} sources`; |
| 4257 | if (evidence.hasConflict || evidence.status === "MIXED_EVIDENCE") { |
| 4258 | return `Mixed evidence / Score ${evidence.score} / ${sources}`; |
| 4259 | } |
| 4260 | return `Score ${evidence.score} / ${sources}`; |
| 4261 | } |
| 4262 | |
| 4263 | function eventsMatching(events: ResearchEvent[], eventTypes: string[]) { |
| 4264 | return events.filter((event) => eventTypes.includes(event.eventType)); |
| 4265 | } |
| 4266 | |
| 4267 | function impactTone(impact: string): "neutral" | "positive" | "negative" | "warning" | "info" { |
| 4268 | if (impact.includes("NEGATIVE")) { |
| 4269 | return "negative"; |
| 4270 | } |
| 4271 | if (impact.includes("POSITIVE")) { |
| 4272 | return "positive"; |
| 4273 | } |
| 4274 | if (impact === "UNCERTAIN") { |
| 4275 | return "warning"; |
| 4276 | } |
| 4277 | return "neutral"; |
| 4278 | } |
| 4279 | |
| 4280 | function researchStatusTone(status: string): "neutral" | "positive" | "negative" | "warning" | "info" { |
| 4281 | if (status === "AVAILABLE" || status === "RESOLVED_RESEARCH_AVAILABLE") { |
| 4282 | return "positive"; |
| 4283 | } |
| 4284 | if (status === "DEGRADED" || status === "RESOLVED_PARTIAL_DATA" || status === "RESOLVED_NO_SOURCES" || status === "RESEARCH_NOT_REFRESHED" || status === "NO_EVIDENCE") { |
| 4285 | return "warning"; |
| 4286 | } |
| 4287 | if (["COMPANY_NOT_RESOLVED", "RESEARCH_PROVIDER_UNAVAILABLE", "SOURCE_DISCOVERY_UNAVAILABLE", "SEARCH_PROVIDER_UNAVAILABLE", "DOCUMENT_FETCH_FAILED"].includes(status)) { |
| 4288 | return "negative"; |
| 4289 | } |
| 4290 | if (["SEARCH_RETURNED_ZERO_RESULTS", "RESULTS_REJECTED", "EXTRACTION_EMPTY"].includes(status)) return "warning"; |
| 4291 | if (status === "RESEARCH_NOT_APPLICABLE" || status === "ETF_UNSUPPORTED") { |
| 4292 | return "info"; |
| 4293 | } |
| 4294 | if (status?.startsWith("ETF_RESEARCH_")) { |
| 4295 | return status === "ETF_RESEARCH_AVAILABLE" ? "positive" : "info"; |
| 4296 | } |
| 4297 | return "neutral"; |
| 4298 | } |
| 4299 | |
| 4300 | function researchStatusDescription(status: string): string { |
| 4301 | if (status === "RESOLVED_RESEARCH_AVAILABLE") return "Research available"; |
| 4302 | if (status === "RESOLVED_PARTIAL_DATA") return "Partial research data — some research sections are unavailable."; |
| 4303 | if (status === "ETF_UNSUPPORTED" || status === "RESEARCH_NOT_APPLICABLE") return "ETF research unsupported"; |
| 4304 | return status.replaceAll("_", " "); |
| 4305 | } |
| 4306 | |
| 4307 | function isResearchSummaryLoadable(status?: string | null) { |
| 4308 | return ["AVAILABLE", "DEGRADED", "RESOLVED_RESEARCH_AVAILABLE", "RESOLVED_PARTIAL_DATA"].includes(status ?? ""); |
| 4309 | } |
| 4310 | |
| 4311 | function canRefreshResearch(company?: PortfolioResearchCompany | null) { |
| 4312 | const status = company?.status ?? ""; |
| 4313 | return Boolean(company?.instrumentId) |
| 4314 | && status !== "ETF_UNSUPPORTED" |
| 4315 | && !status.startsWith("ETF_RESEARCH_") |
| 4316 | && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE"].includes(status); |
| 4317 | } |
| 4318 | |
| 4319 | function canRefreshResearchIdentity( |
| 4320 | globalInstrumentId?: string | null, |
| 4321 | status?: string | null, |
| 4322 | assetType?: string | null, |
| 4323 | ) { |
| 4324 | const normalizedStatus = status ?? ""; |
| 4325 | const normalizedAssetType = (assetType ?? "").toUpperCase(); |
| 4326 | return Boolean(globalInstrumentId?.trim()) |
| 4327 | && !["COMPANY_NOT_RESOLVED", "RESEARCH_NOT_APPLICABLE", "ETF_UNSUPPORTED"].includes(normalizedStatus) |
| 4328 | && !normalizedStatus.startsWith("ETF_RESEARCH_") |
| 4329 | && !["ETF", "FUND", "BOND", "CASH", "CRYPTO"].includes(normalizedAssetType); |
| 4330 | } |
| 4331 | |
| 4332 | function researchEmptyTitle(status?: string | null) { |
| 4333 | if (status === "ETF_UNSUPPORTED") { |
| 4334 | return "ETF company research unsupported"; |
| 4335 | } |
| 4336 | if (status === "ETF_RESEARCH_NOT_REFRESHED") { |
| 4337 | return "ETF research not refreshed"; |
| 4338 | } |
| 4339 | if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") { |
| 4340 | return "ETF research unavailable"; |
| 4341 | } |
| 4342 | if (status === "RESEARCH_NOT_APPLICABLE") { |
| 4343 | return "Research not applicable"; |
| 4344 | } |
| 4345 | if (status === "COMPANY_NOT_RESOLVED") { |
| 4346 | return "Company not resolved"; |
| 4347 | } |
| 4348 | if (status === "RESEARCH_NOT_REFRESHED") { |
| 4349 | return "Research not refreshed"; |
| 4350 | } |
| 4351 | return "Research unavailable"; |
| 4352 | } |
| 4353 | |
| 4354 | function researchEmptyMessage(status?: string | null) { |
| 4355 | if (status === "ETF_RESEARCH_NOT_REFRESHED") { |
| 4356 | return "No shared public ETF research has been collected for this fund yet."; |
| 4357 | } |
| 4358 | if (status === "ETF_RESEARCH_SOURCE_UNAVAILABLE") { |
| 4359 | return "No acceptable public ETF source was collected during the last refresh."; |
| 4360 | } |
| 4361 | if (status === "RESEARCH_NOT_APPLICABLE") { |
| 4362 | return "Company-level catalyst research is not applicable for this asset type."; |
| 4363 | } |
| 4364 | if (status === "COMPANY_NOT_RESOLVED") { |
| 4365 | return "This holding did not match a canonical research company."; |
| 4366 | } |
| 4367 | if (status === "RESEARCH_NOT_REFRESHED") { |
| 4368 | return "No shared public research has been collected for this company yet."; |
| 4369 | } |
| 4370 | return "Select a holding with available company research."; |
| 4371 | } |
| 4372 | |
| 4373 | function SettingsView() { |
| 4374 | return ( |
| 4375 | <Card className="wide-panel"> |
| 4376 | <div className="panel-header"> |
| 4377 | <div> |
| 4378 | <h2>Platform preferences</h2> |
| 4379 | <p>Light, dark, and system theme support is wired for frontend work.</p> |
| 4380 | </div> |
| 4381 | <ShieldCheck size={20} aria-hidden="true" /> |
| 4382 | </div> |
| 4383 | <div className="settings-grid"> |
| 4384 | <div> |
| 4385 | <h3>Data freshness vocabulary</h3> |
| 4386 | <p>REAL-TIME, DELAYED, EOD, STALE, DEMO, and UNAVAILABLE labels are supported. DEMO is never presented as live market data.</p> |
| 4387 | </div> |
| 4388 | <div> |
| 4389 | <h3>Error handling</h3> |
| 4390 | <p>API errors show a user-safe message and correlation ID without stack traces or internal secrets.</p> |
| 4391 | </div> |
| 4392 | <div> |
| 4393 | <h3>Risk indicators</h3> |
| 4394 | <p>Portfolio risk scoring is not yet available.</p> |
| 4395 | </div> |
| 4396 | <div> |
| 4397 | <h3>Recommendation states</h3> |
| 4398 | <p>BUY, ADD, HOLD, TRIM, and SELL badges are design-system ready for future recommendation logic.</p> |
| 4399 | </div> |
| 4400 | </div> |
| 4401 | </Card> |
| 4402 | ); |
| 4403 | } |
| 4404 | |
| 4405 | function brokerDisplayName(brokerType: string) { |
| 4406 | if (brokerType === "ICICI_DIRECT") { |
| 4407 | return "ICICI Direct"; |
| 4408 | } |
| 4409 | if (brokerType === "IBKR") { |
| 4410 | return "Interactive Brokers"; |
| 4411 | } |
| 4412 | if (brokerType === "MOCK") { |
| 4413 | return "Demo Broker"; |
| 4414 | } |
| 4415 | return brokerType; |
| 4416 | } |
| 4417 | |
| 4418 | function StockSearchField({ |
| 4419 | selectedGlobalInstrumentId, |
| 4420 | onSelect, |
| 4421 | }: { |
| 4422 | selectedGlobalInstrumentId?: string; |
| 4423 | onSelect: (match: ResearchInstrumentMatch) => void; |
| 4424 | }) { |
| 4425 | const DEBOUNCE_MS = 300; |
| 4426 | const [region, setRegion] = useState<SectorPerformance["region"]>("INDIA"); |
| 4427 | const [query, setQuery] = useState(""); |
| 4428 | const [results, setResults] = useState<ResearchInstrumentMatch[]>([]); |
| 4429 | const [open, setOpen] = useState(false); |
| 4430 | const [loading, setLoading] = useState(false); |
| 4431 | const [error, setError] = useState<string | null>(null); |
| 4432 | const [highlight, setHighlight] = useState(0); |
| 4433 | const inputRef = useRef<HTMLInputElement>(null); |
| 4434 | |
| 4435 | const cache = useRef(new Map<string, ResearchInstrumentMatch[]>()); |
| 4436 | const trimmed = query.trim(); |
| 4437 | useEffect(() => { |
| 4438 | let cancelled = false; |
| 4439 | setResults([]); |
| 4440 | setOpen(false); |
| 4441 | setLoading(false); |
| 4442 | setError(null); |
| 4443 | if (trimmed.length < 3) return; |
| 4444 | const key = `${region}:${trimmed.toLowerCase()}`; |
| 4445 | const show = (items: ResearchInstrumentMatch[]) => { |
| 4446 | if (cancelled) return; |
| 4447 | setResults(items); |
| 4448 | setOpen(true); |
| 4449 | setHighlight(0); |
| 4450 | }; |
| 4451 | const cached = cache.current.get(key); |
| 4452 | if (cached) { show(cached); return; } |
| 4453 | const active = setTimeout(() => { |
| 4454 | setLoading(true); |
| 4455 | void researchApi.searchInstruments(region, trimmed, 20) |
| 4456 | .then((items) => { |
| 4457 | if (cancelled) return; |
| 4458 | if (cache.current.size >= 50) cache.current.delete(cache.current.keys().next().value!); |
| 4459 | cache.current.set(key, items); |
| 4460 | show(items); |
| 4461 | }) |
| 4462 | .catch(() => { |
| 4463 | if (!cancelled) setError("Stock search unavailable. Please try again."); |
| 4464 | }) |
| 4465 | .finally(() => { if (!cancelled) setLoading(false); }); |
| 4466 | }, DEBOUNCE_MS); |
| 4467 | return () => { cancelled = true; clearTimeout(active); }; |
| 4468 | }, [trimmed, region]); |
| 4469 | |
| 4470 | useEffect(() => { |
| 4471 | if (open) document.getElementById(`research-option-${highlight}`)?.scrollIntoView({ block: "nearest" }); |
| 4472 | }, [highlight, open]); |
| 4473 | |
| 4474 | function commit(highlighted: number) { |
| 4475 | const item = results[highlighted]; |
| 4476 | if (!item) return; |
| 4477 | onSelect(item); |
| 4478 | setQuery(""); |
| 4479 | setResults([]); |
| 4480 | setOpen(false); |
| 4481 | } |
| 4482 | |
| 4483 | const rows = results.slice(0, 20); |
| 4484 | const rowCount = Math.max(1, rows.length); |
| 4485 | |
| 4486 | return ( |
| 4487 | <div className="research-search-control"> |
| 4488 | <div className="research-region-control" role="group" aria-label="Region"> |
| 4489 | <span>Region</span> |
| 4490 | {(["INDIA", "USA", "EUROPE"] as const).map((r) => ( |
| 4491 | <button |
| 4492 | key={r} |
| 4493 | type="button" |
| 4494 | aria-pressed={r === region} |
| 4495 | className={r === region ? "active" : ""} |
| 4496 | onClick={() => { setRegion(r); setQuery(""); setResults([]); setOpen(false); }} |
| 4497 | > |
| 4498 | {r} |
| 4499 | </button> |
| 4500 | ))} |
| 4501 | </div> |
| 4502 | <label htmlFor="research-stock-query">Search stocks</label> |
| 4503 | <input |
| 4504 | id="research-stock-query" |
| 4505 | role="combobox" |
| 4506 | aria-activedescendant={open && rows.length ? `research-option-${highlight}` : undefined} |
| 4507 | ref={inputRef} |
| 4508 | type="search" |
| 4509 | className="research-search-input" |
| 4510 | placeholder="Search by company name, symbol or ISIN..." |
| 4511 | value={query} |
| 4512 | autoComplete="off" |
| 4513 | aria-autocomplete="list" |
| 4514 | aria-expanded={open} |
| 4515 | aria-controls={open ? "research-search-listbox" : undefined} |
| 4516 | onBlur={() => setOpen(false)} |
| 4517 | onFocus={() => { if (trimmed.length >= 3 && results.length) setOpen(true); }} |
| 4518 | onChange={(event) => setQuery(event.target.value)} |
| 4519 | onKeyDown={(event) => { |
| 4520 | if (!open) return; |
| 4521 | if (event.key === "ArrowDown") { |
| 4522 | event.preventDefault(); |
| 4523 | setHighlight((current) => (current + 1) % rowCount); |
| 4524 | } else if (event.key === "ArrowUp") { |
| 4525 | event.preventDefault(); |
| 4526 | setHighlight((current) => (current - 1 + rowCount) % rowCount); |
| 4527 | } else if (event.key === "Enter") { |
| 4528 | event.preventDefault(); |
| 4529 | commit(highlight); |
| 4530 | } else if (event.key === "Escape") { |
| 4531 | event.preventDefault(); |
| 4532 | setOpen(false); |
| 4533 | } |
| 4534 | }} |
| 4535 | /> |
| 4536 | {open && !loading && rows.length === 0 ? <small role="status">No stocks found.</small> : null} |
| 4537 | {loading ? <small role="status">Searching…</small> : null} |
| 4538 | {error ? <small role="alert">{error}</small> : null} |
| 4539 | <ul |
| 4540 | id="research-search-listbox" |
| 4541 | className="research-search-listbox" |
| 4542 | role="listbox" |
| 4543 | hidden={!open || rows.length === 0} |
| 4544 | style={{ position: "absolute", background: "var(--surface-raised)", border: "1px solid var(--border)", maxHeight: "240px", overflowY: "auto", zIndex: 1000 }} |
| 4545 | > |
| 4546 | {rows.map((item, index) => ( |
| 4547 | <li |
| 4548 | id={`research-option-${index}`} |
| 4549 | key={item.globalInstrumentId} |
| 4550 | role="option" |
| 4551 | aria-selected={index === highlight} |
| 4552 | className={index === highlight ? "highlighted" : ""} |
| 4553 | onMouseDown={(event) => { |
| 4554 | event.preventDefault(); |
| 4555 | commit(index); |
| 4556 | }} |
| 4557 | > |
| 4558 | <strong>{item.companyName}</strong> |
| 4559 | <small>{[item.canonicalSymbol ?? item.symbol, item.exchange, item.isin].filter(Boolean).join(" · ")}</small> |
| 4560 | {item.sector ? <small>{item.sector}</small> : null} |
| 4561 | </li> |
| 4562 | ))} |
| 4563 | </ul> |
| 4564 | </div> |
| 4565 | ); |
| 4566 | } |