996 lines
46 KiB
TypeScript
996 lines
46 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||
import { Loader2, DollarSign, RefreshCw, ChevronRight, ChevronDown } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { getFinancialData } from "@/lib/api"
|
||
import { formatNumber, formatLargeNumber, formatDate } from "@/lib/formatters"
|
||
import type { FinancialDataResponse } from "@/lib/types"
|
||
|
||
interface BloombergViewProps {
|
||
companyId: number
|
||
onBack?: () => void
|
||
selectedCurrency?: string
|
||
userMarket?: string
|
||
lastUpdate?: string
|
||
}
|
||
|
||
export function BloombergView({ companyId, onBack, selectedCurrency = "Auto", userMarket, lastUpdate }: BloombergViewProps) {
|
||
const [data, setData] = useState<FinancialDataResponse | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState("")
|
||
|
||
const loadData = async () => {
|
||
if (!companyId) return
|
||
|
||
setLoading(true)
|
||
setError("")
|
||
try {
|
||
console.log("Fetching Bloomberg data for company:", companyId)
|
||
const result = await getFinancialData(companyId, "Bloomberg")
|
||
console.log("Bloomberg data result:", result)
|
||
setData(result)
|
||
} catch (err: any) {
|
||
console.error("Bloomberg fetch error:", err)
|
||
setError(err.message || "加载 Bloomberg 数据失败")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [companyId, lastUpdate])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||
<p className="text-muted-foreground">正在加载 Bloomberg 原始数据...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card className="border-destructive">
|
||
<CardContent className="pt-6">
|
||
<div className="text-center text-destructive space-y-4">
|
||
<p>{error}</p>
|
||
<Button variant="outline" onClick={loadData}>重试</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) return null
|
||
|
||
// 如果后端提供了统一数据字段,直接使用
|
||
const mergedData = data.unified_data || []
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||
<div className="flex items-center gap-6">
|
||
<h2 className="text-2xl font-bold flex items-center gap-2 whitespace-nowrap">
|
||
<DollarSign className="w-6 h-6" />
|
||
财务概览
|
||
</h2>
|
||
{/* Insert Inline Header Info here */}
|
||
<BasicInfoHeader data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
|
||
</div>
|
||
|
||
<div className="flex gap-2 ml-auto">
|
||
<Button variant="outline" size="sm" onClick={loadData}>
|
||
<RefreshCw className="w-4 h-4 mr-2" />
|
||
刷新数据
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<RawDataTable title="财务数据总表" data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Basic Info Header Component
|
||
// ---------------------------------------------------------------------------
|
||
function BasicInfoHeader({ data, selectedCurrency = "Auto", userMarket }: { data: any[], selectedCurrency?: string, userMarket?: string }) {
|
||
if (!data || data.length === 0) return null
|
||
|
||
// 1. Determine Target Currency (Logic shared with Table)
|
||
let targetCurrency = selectedCurrency
|
||
if (targetCurrency === "Auto" && userMarket) {
|
||
const market = userMarket.toUpperCase()
|
||
if (market.includes("JP")) targetCurrency = "JPY"
|
||
else if (market.includes("VN")) targetCurrency = "VND"
|
||
else if (market.includes("CH")) targetCurrency = "CNY"
|
||
else if (market.includes("HK")) targetCurrency = "HKD"
|
||
else targetCurrency = "USD"
|
||
}
|
||
|
||
// 2. Find Latest Valid Data for each field
|
||
// We sort data by date descending to get latest first.
|
||
// Note: 'end_date' is the reporting period, 'update_date' is when it was fetched.
|
||
// Usually PE/PB/MarketCap are "current" values, so they might be associated with the latest reporting period or a special "current" row.
|
||
// Based on bloomberg logic, they are saved as time series. We take the latest available.
|
||
|
||
// Sort rows by date descending
|
||
const sortedRows = [...data].sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime())
|
||
|
||
// Helper to find first non-null value
|
||
const findValue = (keys: string[]) => {
|
||
for (const row of sortedRows) {
|
||
// Check currency match if required (PE/PB/MarketCap are currency dependent usually, or at least MarketCap is)
|
||
// However, PE/PB are ratios.
|
||
// Strict currency check for everything to be safe?
|
||
if (targetCurrency !== 'Auto') {
|
||
if (row.currency && row.currency !== targetCurrency) continue
|
||
} else {
|
||
// If auto, try to match first row's currency if established, or just take any?
|
||
// Let's stick to the targetCurrency logic used in Table which resolved 'Auto' to a specific one if possible.
|
||
}
|
||
|
||
for (const key of keys) {
|
||
if (row[key] !== null && row[key] !== undefined && row[key] !== '') {
|
||
return { value: row[key], row }
|
||
}
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// Extract Fields
|
||
const companyName = findValue(['company_name'])?.value || '-'
|
||
|
||
// Find latest update_date from ALL rows matching currency
|
||
let maxUpdateDate = ''
|
||
for (const row of data) {
|
||
if (targetCurrency !== 'Auto' && row.currency && row.currency !== targetCurrency) continue
|
||
if (row.update_date && row.update_date > maxUpdateDate) {
|
||
maxUpdateDate = row.update_date
|
||
}
|
||
}
|
||
const updateDate = maxUpdateDate
|
||
// update_date is a timestamp string "2023-10-xx 10:00:00". User wants "Day only".
|
||
const displayDate = updateDate ? formatDate(updateDate) : '-'
|
||
|
||
const pe = findValue(['pe', 'pe_ratio'])?.value
|
||
const pb = findValue(['pb', 'pb_ratio'])?.value
|
||
const marketCapTuple = findValue(['market_cap'])
|
||
const marketCap = marketCapTuple?.value
|
||
const marketCapCurrency = marketCapTuple?.row?.currency || targetCurrency
|
||
|
||
const abroadRev = findValue(['rev_abroad', 'pct_revenue_from_foreign_sources'])?.value
|
||
const divYield = findValue(['dividend_yield', 'dividend_12_month_yield'])?.value
|
||
const ipoDateRaw = findValue(['ipo_date', 'IPO_date', 'eqy_init_po_dt'])?.value
|
||
const ipoDate = ipoDateRaw ? formatDate(ipoDateRaw) : '-'
|
||
|
||
// Formatters
|
||
const formatRatio = (val: any) => {
|
||
if (val === undefined || val === null) return '-'
|
||
const num = parseFloat(val)
|
||
if (isNaN(num)) return '-'
|
||
return num.toFixed(2)
|
||
}
|
||
|
||
const formatPercent = (val: any) => {
|
||
if (val === undefined || val === null) return '-'
|
||
const num = parseFloat(val)
|
||
if (isNaN(num)) return '-'
|
||
return num.toFixed(2) + '%'
|
||
}
|
||
|
||
const formatMoney = (val: any, currency: string) => {
|
||
if (val === undefined || val === null) return '-'
|
||
let num = parseFloat(val)
|
||
if (isNaN(num)) return '-'
|
||
|
||
// Backend now returns Market Cap in Millions for ALL currencies (via BDH).
|
||
// To convert Millions to "Yi" (100 Million), we divide by 100.
|
||
// This applies uniformly to JPY, USD, CNY, etc.
|
||
num = num / 100
|
||
|
||
return num.toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' 亿'
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-6 text-sm bg-muted/30 px-4 py-2 rounded-lg border">
|
||
{/* Group 1: Identity */}
|
||
<div className="flex flex-col gap-0.5 border-r pr-6">
|
||
<span className="font-bold text-lg text-primary truncate max-w-[200px]" title={companyName}>{companyName}</span>
|
||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||
<span>IPO: {ipoDate}</span>
|
||
<span>更新: {displayDate}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Group 2: Key Ratios */}
|
||
<div className="flex items-center gap-6">
|
||
<div className="flex flex-col items-center">
|
||
<span className="text-muted-foreground text-xs scale-90">PE</span>
|
||
<span className="font-mono font-bold text-base">{formatRatio(pe)}</span>
|
||
</div>
|
||
<div className="flex flex-col items-center">
|
||
<span className="text-muted-foreground text-xs scale-90">PB</span>
|
||
<span className="font-mono font-bold text-base">{formatRatio(pb)}</span>
|
||
</div>
|
||
<div className="flex flex-col items-center">
|
||
<span className="text-muted-foreground text-xs scale-90">股息率</span>
|
||
<span className="font-mono font-bold text-base">{formatPercent(divYield)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Group 3: Market Size & Biz */}
|
||
<div className="flex items-center gap-6 border-l pl-6">
|
||
<div className="flex flex-col items-end">
|
||
<span className="text-muted-foreground text-xs scale-90">市值 ({marketCapCurrency})</span>
|
||
<span className="font-mono font-bold text-base text-blue-600 dark:text-blue-400">{formatMoney(marketCap, marketCapCurrency)}</span>
|
||
</div>
|
||
<div className="flex flex-col items-end">
|
||
<span className="text-muted-foreground text-xs scale-90">海外收入</span>
|
||
<span className="font-mono font-bold text-base">{formatPercent(abroadRev)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RawDataTable({ data, title, selectedCurrency = "Auto", userMarket }: { data: any[], title: string, selectedCurrency?: string, userMarket?: string }) {
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">{title}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="text-center text-muted-foreground py-8">暂无数据</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Filter Data Rows (Handle Multi-Currency) - DO THIS FIRST!
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Determine Target Currency Strategy
|
||
let targetCurrency = selectedCurrency
|
||
if (targetCurrency === "Auto" && userMarket) {
|
||
const market = userMarket.toUpperCase()
|
||
if (market.includes("JP")) targetCurrency = "JPY"
|
||
else if (market.includes("VN")) targetCurrency = "VND"
|
||
else if (market.includes("CH")) targetCurrency = "CNY"
|
||
else if (market.includes("HK")) targetCurrency = "HKD"
|
||
else targetCurrency = "USD"
|
||
}
|
||
|
||
const filteredRows = data.filter(row => {
|
||
// Basic Validity Check
|
||
if (!row || row.revenue === null || row.revenue === undefined) return false
|
||
|
||
// Currency Filtering
|
||
// Case 1: Specific Currency Selected (or resolved from Auto)
|
||
if (targetCurrency !== "Auto") {
|
||
if (row.currency && row.currency !== targetCurrency) {
|
||
return false
|
||
}
|
||
}
|
||
// Case 2: Pure Auto (Fallback if no userMarket)
|
||
else if (selectedCurrency === "Auto") {
|
||
if (data.length > 0 && data[0].currency && row.currency && row.currency !== data[0].currency) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1.5 Enrich Data with Ratios (Expense / Revenue)
|
||
// ---------------------------------------------------------------------------
|
||
filteredRows.forEach(row => {
|
||
const revenue = typeof row.revenue === 'string' ? parseFloat(row.revenue) : row.revenue
|
||
|
||
if (revenue && revenue !== 0) {
|
||
// Helper to calculate and assign if value exists
|
||
const calcRatio = (key: string, targetKey: string) => {
|
||
const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key]
|
||
if (val !== null && val !== undefined && !isNaN(val)) {
|
||
row[targetKey] = (val / revenue) * 100
|
||
}
|
||
}
|
||
|
||
calcRatio('selling&marketing', 'selling&marketing_to_revenue')
|
||
calcRatio('general&admin', 'general&admin_to_revenue')
|
||
calcRatio('sg&a', 'sg&a_to_revenue')
|
||
calcRatio('r&d', 'r&d_to_revenue')
|
||
calcRatio('depreciation', 'depreciation_to_revenue')
|
||
// Asset Ratios (Denominator: Total Assets)
|
||
const calcAssetRatio = (key: string, targetKey: string) => {
|
||
const totalAssets = typeof row['total_assets'] === 'string' ? parseFloat(row['total_assets']) : row['total_assets']
|
||
const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key]
|
||
if (totalAssets && totalAssets !== 0 && val !== null && val !== undefined && !isNaN(val)) {
|
||
row[targetKey] = (val / totalAssets) * 100
|
||
}
|
||
}
|
||
calcAssetRatio('cash', 'cash_to_assets')
|
||
calcAssetRatio('inventory', 'inventory_to_assets')
|
||
calcAssetRatio('accounts¬es_receivable', 'receivables_to_assets')
|
||
calcAssetRatio('prepaid', 'prepaid_to_assets')
|
||
calcAssetRatio('property_plant&equipment', 'ppe_to_assets')
|
||
calcAssetRatio('lt_investment', 'lt_investment_to_assets')
|
||
calcAssetRatio('goodwill', 'goodwill_to_assets')
|
||
calcAssetRatio('accounts_payable', 'payables_to_assets')
|
||
calcAssetRatio('st_defer_rev', 'deferred_revenue_to_assets')
|
||
calcAssetRatio('st_debt', 'st_debt_to_assets')
|
||
calcAssetRatio('lt_debt', 'lt_debt_to_assets')
|
||
|
||
// Calculate Other Assets Ratio
|
||
// Formula: 100 - Cash% - Inventory% - Receivables% - Prepaid% - PP&E% - LT Investment%
|
||
if (row['total_assets']) {
|
||
const getR = (k: string) => (typeof row[k] === 'number' ? row[k] : 0)
|
||
const sumKnown = getR('cash_to_assets') + getR('inventory_to_assets') +
|
||
getR('receivables_to_assets') + getR('prepaid_to_assets') +
|
||
getR('ppe_to_assets') + getR('lt_investment_to_assets') +
|
||
getR('goodwill_to_assets')
|
||
row['other_assets_ratio'] = 100 - sumKnown
|
||
}
|
||
|
||
// Calculate Other Expense Ratio
|
||
// Formula: Gross Margin - Net Margin - SG&A% - R&D%
|
||
// Note: All values are expected to be in Percentage (0-100) scale
|
||
const getVal = (k: string) => {
|
||
const v = row[k]
|
||
if (v === null || v === undefined) return 0
|
||
const num = typeof v === 'string' ? parseFloat(v) : v
|
||
return (typeof num === 'number' && !isNaN(num)) ? num : 0
|
||
}
|
||
|
||
// Check if we have the necessary components to make a meaningful calculation
|
||
// We need at least Gross Margin and Net Profit Margin to be present
|
||
// Note: We check against null/undefined, but getVal handles parsing so we can be more lenient in check or rely on getVal result (though 0 might be valid)
|
||
// It's safer to try calculating if keys exist
|
||
if (row['gross_margin'] !== undefined && row['net_profit_margin'] !== undefined) {
|
||
const gross = getVal('gross_margin')
|
||
const net = getVal('net_profit_margin')
|
||
const sga = getVal('sg&a_to_revenue')
|
||
const rd = getVal('r&d_to_revenue')
|
||
|
||
row['other_expense_ratio'] = gross - net - sga - rd
|
||
|
||
// Calculate Breakdown Rows
|
||
row['other_breakdown_gross'] = gross
|
||
row['other_breakdown_net'] = -1 * net
|
||
row['other_breakdown_sga'] = -1 * sga
|
||
row['other_breakdown_rd'] = -1 * rd
|
||
}
|
||
|
||
// Calculate Per Employee Metrics (Unit: Wan)
|
||
// Revenue input is in Millions (based on formatMoney logic / 100 -> Yi)
|
||
// Metric = (Val * 1,000,000) / Employee / 10,000
|
||
// Metric = (Val * 100) / Employee
|
||
const emp = getVal('num_of_employees') || getVal('employee') // handle both keys if present
|
||
if (emp && emp > 0) {
|
||
const rev = typeof row['revenue'] === 'string' ? parseFloat(row['revenue']) : row['revenue']
|
||
const net = typeof row['net_income'] === 'string' ? parseFloat(row['net_income']) : row['net_income']
|
||
|
||
if (rev !== undefined && !isNaN(rev)) {
|
||
row['revenue_per_employee'] = (rev * 100) / emp
|
||
}
|
||
if (net !== undefined && !isNaN(net)) {
|
||
row['profit_per_employee'] = (net * 100) / emp
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Extract Indicators (From Filtered Data ONLY)
|
||
// ---------------------------------------------------------------------------
|
||
// 提取所有指标键值 (排除元数据)
|
||
const excludeKeys = ['id', 'company_code', 'code', 'symbol', 'market', 'update_date', 'create_time', 'end_date', 'ts_code']
|
||
|
||
// 从合并后的数据中收集所有可能的指标
|
||
const allIndicators = new Set<string>()
|
||
filteredRows.forEach(row => {
|
||
Object.keys(row).forEach(k => {
|
||
if (!excludeKeys.includes(k)) {
|
||
allIndicators.add(k)
|
||
}
|
||
})
|
||
})
|
||
|
||
// 对指标进行逻辑排序/分组
|
||
const priorityOrder = [
|
||
'currency',
|
||
'__SECTION_MAIN__',
|
||
'roe', 'roce', 'roa',
|
||
'gross_margin', 'net_profit_margin', 'ebitda_margin',
|
||
'revenue', 'revenue_growth',
|
||
'net_income', 'netincome_growth',
|
||
'cash_from_operating', 'capital_expenditure', 'free_cash_flow',
|
||
'dividends_paid', 'dividend_payout_ratio', 'repurchase',
|
||
'total_assets', 'equity',
|
||
'__SECTION_EXPENSE__',
|
||
'selling&marketing_to_revenue', 'selling&marketing',
|
||
'general&admin_to_revenue', 'general&admin',
|
||
'sg&a_to_revenue', 'sg&a',
|
||
'r&d_to_revenue', 'r&d',
|
||
'other_expense_ratio',
|
||
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
|
||
'depreciation_to_revenue', 'depreciation',
|
||
'tax_rate',
|
||
'__SECTION_ASSET__',
|
||
'cash_to_assets', 'cash',
|
||
'inventory_to_assets', 'inventory',
|
||
'receivables_to_assets', 'accounts¬es_receivable',
|
||
'prepaid_to_assets', 'prepaid',
|
||
'ppe_to_assets', 'property_plant&equipment',
|
||
'lt_investment_to_assets', 'lt_investment',
|
||
'goodwill_to_assets', 'goodwill',
|
||
'other_assets_ratio',
|
||
'payables_to_assets', 'accounts_payable',
|
||
'deferred_revenue_to_assets', 'st_defer_rev',
|
||
'st_debt_to_assets', 'st_debt',
|
||
'lt_debt_to_assets', 'lt_debt',
|
||
'total_debt_ratio',
|
||
'__SECTION_TURNOVER__',
|
||
'inventory_days', 'days_sales_outstanding', 'payables_days',
|
||
'net_fixed_asset_turnover', 'asset_turnover',
|
||
'__SECTION_EFFICIENCY__',
|
||
'employee', 'num_of_employees',
|
||
'revenue_per_employee', 'profit_per_employee',
|
||
'__SECTION_MARKET__',
|
||
'last_price', 'market_cap', 'pe', 'pb',
|
||
'dividend_yield', 'shareholders'
|
||
]
|
||
|
||
// Ensure section header is in the list to be sorted
|
||
const sectionKeys = [
|
||
'__SECTION_MAIN__', '__SECTION_EXPENSE__', '__SECTION_ASSET__',
|
||
'__SECTION_TURNOVER__', '__SECTION_EFFICIENCY__', '__SECTION_MARKET__',
|
||
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
|
||
]
|
||
sectionKeys.forEach(key => {
|
||
if (!allIndicators.has(key)) {
|
||
allIndicators.add(key)
|
||
}
|
||
})
|
||
|
||
const sortedIndicators = Array.from(allIndicators).sort((a, b) => {
|
||
const idxA = priorityOrder.indexOf(a)
|
||
const idxB = priorityOrder.indexOf(b)
|
||
|
||
// Items in priority list come first, sorted by their order in the list
|
||
if (idxA !== -1 && idxB !== -1) return idxA - idxB
|
||
|
||
// Items in priority list come before items not in list
|
||
if (idxA !== -1) return -1
|
||
if (idxB !== -1) return 1
|
||
|
||
// Remaining items sorted alphabetically
|
||
return a.localeCompare(b)
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Filter Data Rows (Handle Multi-Currency)
|
||
// ---------------------------------------------------------------------------
|
||
// We must filter BEFORE creating the Map, because multiple rows might exist
|
||
// for the same date (e.g. 2023 JPY and 2023 USD).
|
||
// The Map key is 'end_date', so it can only hold one currency's data per date.
|
||
|
||
|
||
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Build Data Structures from Filtered Rows
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 构建查找表: Map<DateString, RowData>
|
||
const dataMap = new Map()
|
||
filteredRows.forEach(row => {
|
||
dataMap.set(row.end_date, row)
|
||
})
|
||
|
||
// 提取日期列表
|
||
const dates = Array.from(new Set(filteredRows.map(row => row.end_date)))
|
||
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
|
||
|
||
|
||
// Tooltip State
|
||
const [tooltip, setTooltip] = useState<{ show: boolean, x: number, y: number, text: string }>({
|
||
show: false, x: 0, y: 0, text: ''
|
||
})
|
||
|
||
// Interactive Rows State
|
||
const [expandedMetrics, setExpandedMetrics] = useState<Set<string>>(new Set())
|
||
|
||
const ratioToAbsMap: Record<string, string | string[]> = {
|
||
'selling&marketing_to_revenue': 'selling&marketing',
|
||
'general&admin_to_revenue': 'general&admin',
|
||
'sg&a_to_revenue': 'sg&a',
|
||
'r&d_to_revenue': 'r&d',
|
||
'depreciation_to_revenue': 'depreciation',
|
||
'other_expense_ratio': ['other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd'],
|
||
// Asset Ratios
|
||
'cash_to_assets': 'cash',
|
||
'inventory_to_assets': 'inventory',
|
||
'receivables_to_assets': 'accounts¬es_receivable',
|
||
'prepaid_to_assets': 'prepaid',
|
||
'ppe_to_assets': 'property_plant&equipment',
|
||
'lt_investment_to_assets': 'lt_investment',
|
||
'goodwill_to_assets': 'goodwill',
|
||
'payables_to_assets': 'accounts_payable',
|
||
'deferred_revenue_to_assets': 'st_defer_rev',
|
||
'st_debt_to_assets': 'st_debt',
|
||
'lt_debt_to_assets': 'lt_debt',
|
||
}
|
||
const hiddenByDefault = new Set<string>()
|
||
Object.values(ratioToAbsMap).forEach(val => {
|
||
if (Array.isArray(val)) {
|
||
val.forEach(v => hiddenByDefault.add(v))
|
||
} else {
|
||
hiddenByDefault.add(val)
|
||
}
|
||
})
|
||
|
||
// Ensure virtual keys are included in sortedIndicators
|
||
// (Handled via sectionKeys logic above)
|
||
|
||
const handleRowClick = (indicator: string) => {
|
||
const target = ratioToAbsMap[indicator]
|
||
if (!target) return
|
||
|
||
setExpandedMetrics(prev => {
|
||
const next = new Set(prev)
|
||
|
||
const targets = Array.isArray(target) ? target : [target]
|
||
|
||
// Toggle logic: If first target is present, remove all. Else add all.
|
||
// This ensures consistent state even if desynced.
|
||
const isExpanded = next.has(targets[0])
|
||
|
||
targets.forEach(t => {
|
||
if (isExpanded) {
|
||
next.delete(t)
|
||
} else {
|
||
next.add(t)
|
||
}
|
||
})
|
||
return next
|
||
})
|
||
}
|
||
|
||
const handleMouseMove = (e: React.MouseEvent, text: string) => {
|
||
setTooltip({
|
||
show: true,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
text
|
||
})
|
||
}
|
||
|
||
const handleMouseLeave = () => {
|
||
setTooltip(prev => ({ ...prev, show: false }))
|
||
}
|
||
|
||
// 获取第一行数据的货币单位 (假设一致)
|
||
const currency = data[0]?.currency || 'CNY'
|
||
|
||
return (
|
||
<div className="relative">
|
||
{tooltip.show && (
|
||
<div
|
||
className="fixed z-50 px-2 py-1 text-xs text-white bg-black/90 rounded pointer-events-none whitespace-nowrap"
|
||
style={{
|
||
left: tooltip.x + 10,
|
||
top: tooltip.y + 10
|
||
}}
|
||
>
|
||
{tooltip.text}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-md border">
|
||
<table className="w-full caption-bottom text-sm">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-[200px] sticky left-0 top-0 bg-background z-50 border-r border-b shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
|
||
<div className="flex flex-col gap-1 py-1">
|
||
<span className="font-bold text-foreground">指标 / 报告期</span>
|
||
<span className="text-xs font-normal text-muted-foreground">(单位: 亿)</span>
|
||
</div>
|
||
</TableHead>
|
||
{dates.map(date => (
|
||
<TableHead key={date} className="text-right min-w-[120px] px-4 font-mono sticky top-0 bg-background z-40 border-b">
|
||
{formatDate(date)}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{sortedIndicators.map(indicator => {
|
||
if (indicator === '__SECTION_MAIN__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
主要指标
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
if (indicator === '__SECTION_EXPENSE__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
费用指标
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
if (indicator === '__SECTION_ASSET__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
资产结构
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
if (indicator === '__SECTION_TURNOVER__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
周转能力
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
if (indicator === '__SECTION_EFFICIENCY__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
人均效率
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
if (indicator === '__SECTION_MARKET__') {
|
||
return (
|
||
<TableRow key={indicator} className="bg-muted">
|
||
<TableCell
|
||
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
|
||
colSpan={1}
|
||
>
|
||
市场表现
|
||
</TableCell>
|
||
<TableCell colSpan={dates.length} className="bg-muted p-0" />
|
||
</TableRow>
|
||
)
|
||
}
|
||
|
||
// Check visibility
|
||
const isHidden = hiddenByDefault.has(indicator) && !expandedMetrics.has(indicator)
|
||
if (isHidden) return null
|
||
|
||
const isTrigger = indicator in ratioToAbsMap
|
||
|
||
// Check expansion state for array targets
|
||
let isExpanded = false
|
||
if (isTrigger) {
|
||
const target = ratioToAbsMap[indicator]
|
||
const firstTarget = Array.isArray(target) ? target[0] : target
|
||
isExpanded = expandedMetrics.has(firstTarget)
|
||
}
|
||
|
||
const isSubRow = hiddenByDefault.has(indicator)
|
||
const subRowClass = isSubRow ? "text-xs text-muted-foreground" : ""
|
||
|
||
return (
|
||
<TableRow
|
||
key={indicator}
|
||
className={`hover:bg-purple-100 dark:hover:bg-purple-900/40 ${isHidden ? 'hidden' : ''} ${subRowClass}`}
|
||
>
|
||
<TableCell
|
||
className={`font-medium sticky left-0 bg-background z-10 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] transition-colors hover:bg-muted ${['revenue_growth', 'netincome_growth'].includes(indicator) ? 'text-blue-600 italic dark:text-blue-400' : ''
|
||
} ${isTrigger ? 'cursor-pointer' : 'cursor-help'}`}
|
||
onMouseMove={(e) => !isTrigger && handleMouseMove(e, indicator)}
|
||
onMouseLeave={handleMouseLeave}
|
||
onClick={() => isTrigger && handleRowClick(indicator)}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
{isTrigger && (
|
||
isExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||
)}
|
||
<span className={isTrigger ? "" : (hiddenByDefault.has(indicator) ? "pl-8" : "")}>
|
||
{formatColumnName(indicator)}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
{dates.map(date => {
|
||
const row = dataMap.get(date)
|
||
const value = row ? row[indicator] : null
|
||
|
||
let highlightClass = ''
|
||
let textStyle = ''
|
||
|
||
if (value !== null && value !== undefined) {
|
||
const numVal = typeof value === 'string' ? parseFloat(value) : value
|
||
if (typeof numVal === 'number' && !isNaN(numVal)) {
|
||
const assetRatios = [
|
||
'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets',
|
||
'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets',
|
||
'payables_to_assets', 'deferred_revenue_to_assets',
|
||
'st_debt_to_assets', 'lt_debt_to_assets', 'other_assets_ratio',
|
||
'goodwill_to_assets'
|
||
]
|
||
|
||
if (assetRatios.includes(indicator) && numVal > 30) {
|
||
highlightClass = 'bg-red-100 dark:bg-red-900/40 font-bold'
|
||
}
|
||
else if (indicator === 'other_assets_ratio') highlightClass = 'bg-yellow-100 dark:bg-yellow-900/40'
|
||
else if (indicator === 'roe' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (indicator === 'gross_margin' && numVal > 40) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (indicator === 'net_profit_margin' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (indicator === 'roce' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (indicator === 'roa' && numVal > 10) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (indicator === 'revenue_growth') {
|
||
if (numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
|
||
}
|
||
else if (indicator === 'netincome_growth') {
|
||
if (numVal > 20) highlightClass = 'bg-green-100 dark:bg-green-900/40'
|
||
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
|
||
}
|
||
|
||
// Dynamic text style based on value for growth rates
|
||
if (['revenue_growth', 'netincome_growth'].includes(indicator)) {
|
||
if (numVal < 0) {
|
||
textStyle = 'text-red-600 italic font-bold dark:text-red-400'
|
||
} else {
|
||
textStyle = 'text-blue-600 italic dark:text-blue-400'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback for text style if value is missing/invalid but indicator is growth rate
|
||
if (textStyle === '' && ['revenue_growth', 'netincome_growth'].includes(indicator)) {
|
||
textStyle = 'text-blue-600 italic dark:text-blue-400'
|
||
}
|
||
|
||
return (
|
||
<TableCell key={date} className={`text-right px-4 tabular-nums ${highlightClass} ${textStyle}`} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
|
||
{formatCellValue(indicator, value)}
|
||
</TableCell>
|
||
)
|
||
})}
|
||
</TableRow>
|
||
)
|
||
})}
|
||
</TableBody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 复用之前的 formatting 逻辑
|
||
function formatColumnName(column: string): string {
|
||
const nameMap: Record<string, string> = {
|
||
// --- 核心利润表 ---
|
||
'revenue': '营业收入',
|
||
'net_income': '净利润',
|
||
'operating_profit': '营业利润',
|
||
'gross_margin': '毛利率',
|
||
'net_profit_margin': '净利率',
|
||
'ebitda_margin': 'EBITDA利润率',
|
||
'sg&a': '销售管理费用(SG&A)',
|
||
'sg&a_to_revenue': '销售管理费用率',
|
||
'selling&marketing': '销售费用',
|
||
'selling&marketing_to_revenue': '销售费用率',
|
||
'general&admin': '管理费用',
|
||
'general&admin_to_revenue': '管理费用率',
|
||
'ga_exp': '管理费用', // Alias
|
||
'rd_exp': '研发费用',
|
||
'r&d': '研发费用',
|
||
'r&d_to_revenue': '研发费用率',
|
||
'other_expense_ratio': '其他费用率',
|
||
'other_breakdown_gross': '毛利率',
|
||
'other_breakdown_net': '(-) 净利率',
|
||
'other_breakdown_sga': '(-) 销售管理费用率(SG&A)',
|
||
'other_breakdown_rd': '(-) 研发费用率',
|
||
'depreciation': '折旧',
|
||
'depreciation_to_revenue': '折旧率',
|
||
'tax_rate': '有效税率',
|
||
'dividends': '分红',
|
||
'dividend_payout_ratio': '分红支付率',
|
||
'netincome_growth': '净利润增长率',
|
||
'revenue_growth': '营收增长率',
|
||
|
||
// --- 资产负债表 ---
|
||
'total_assets': '总资产',
|
||
'total_liabilities': '总负债',
|
||
'shareholders_equity': '净资产', // Alias
|
||
'total_equity': '净资产', // Alias
|
||
'equity': '净资产', // Real Bloomberg Key
|
||
'cash': '现金',
|
||
'inventory': '存货',
|
||
'accounts¬es_receivable': '应收账款及票据',
|
||
'receivables': '应收账款',
|
||
'accounts_payable': '应付账款',
|
||
'prepayment': '预付款项',
|
||
'prepaid': '预付款项',
|
||
'fixed_assets': '固定资产',
|
||
'property_plant&equipment': '固定资产(PP&E)',
|
||
'long_term_investments': '长期投资',
|
||
'lt_investment': '长期投资',
|
||
'short_term_debt': '短期借款',
|
||
'st_debt': '短期借款',
|
||
'st_debt_to_assets': '短期借款占比',
|
||
'long_term_debt': '长期借款',
|
||
'lt_debt': '长期借款',
|
||
'lt_debt_to_assets': '长期借款占比',
|
||
'deferred_revenue': '递延收入',
|
||
'st_defer_rev': '短期递延收入',
|
||
'deferred_revenue_to_assets': '预收占比',
|
||
'goodwill': '商誉',
|
||
'goodwill_to_assets': '商誉占比',
|
||
'total_debt_ratio': '总债务比率',
|
||
'cash_to_assets': '现金占比',
|
||
'inventory_to_assets': '存货占比',
|
||
'receivables_to_assets': '应收占比',
|
||
'prepaid_to_assets': '预付占比',
|
||
'ppe_to_assets': '固定资产占比',
|
||
'lt_investment_to_assets': '长期投资占比',
|
||
'payables_to_assets': '应付占比',
|
||
'other_assets_ratio': '其他资产占比',
|
||
|
||
// --- 现金流量表 ---
|
||
'cash_from_operating': '经营现金流',
|
||
'ocf': '经营现金流', // Alias
|
||
'capital_expenditure': '资本支出',
|
||
'capex': '资本支出', // Alias
|
||
'free_cash_flow': '自由现金流',
|
||
'fcf': '自由现金流', // Alias
|
||
'dividends_paid': '支付股息',
|
||
'repurchase': '股份回购',
|
||
|
||
// --- 运营效率/周转 ---
|
||
'asset_turnover': '总资产周转率',
|
||
'net_fixed_asset_turnover': '固定资产周转率', // Correct Bloomberg Key
|
||
'net_fixed_asset_turn': '固定资产周转率', // Alias just in case
|
||
'inventory_days': '存货周转天数',
|
||
'invent_days': '存货周转天数',
|
||
'days_sales_outstanding': '应收账款周转天数',
|
||
'payables_days': '应付账款周转天数',
|
||
'employee': '员工人数',
|
||
'num_of_employees': '员工人数',
|
||
'revenue_per_employee': '人均创收(万)',
|
||
'profit_per_employee': '人均创利(万)',
|
||
|
||
// --- 估值与市场 ---
|
||
'pe': '市盈率(PE)',
|
||
'pe_ratio': '市盈率(PE)',
|
||
'pb': '市净率(PB)',
|
||
'pb_ratio': '市净率(PB)',
|
||
'market_cap': '市值',
|
||
'price': '最新股价',
|
||
'last_price': '最新股价',
|
||
'roe': 'ROE',
|
||
'roa': 'ROA',
|
||
'roce': 'ROCE',
|
||
'dividend_yield': '股息率',
|
||
'shareholders': '股东人数',
|
||
'company_name': '公司名称',
|
||
'ipo_date': '上市日期',
|
||
'rev_abroad': '海外收入占比',
|
||
'currency': '货币',
|
||
}
|
||
return nameMap[column.toLowerCase()] || column
|
||
}
|
||
|
||
function formatCellValue(column: string, value: any): string {
|
||
if (value === null || value === undefined) return '-'
|
||
|
||
// 尝试解析文本数字
|
||
let numVal = value
|
||
if (typeof value === 'string') {
|
||
const parsed = parseFloat(value)
|
||
if (!isNaN(parsed)) {
|
||
numVal = parsed
|
||
}
|
||
}
|
||
|
||
if (typeof numVal === 'number') {
|
||
const lowerCol = column.toLowerCase()
|
||
|
||
// Special scaling for tax_rate
|
||
if (lowerCol === 'tax_rate') {
|
||
numVal = numVal * 100
|
||
}
|
||
|
||
const isRatio = ['roe', 'roce', 'roa', 'gross_margin', 'net_profit_margin', 'ebitda_margin', 'dividend_yield', 'rev_abroad', 'tax_rate', 'revenue_growth', 'netincome_growth',
|
||
'selling&marketing_to_revenue', 'general&admin_to_revenue', 'sg&a_to_revenue', 'r&d_to_revenue', 'depreciation_to_revenue',
|
||
'other_expense_ratio',
|
||
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
|
||
'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets', 'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets',
|
||
'payables_to_assets', 'deferred_revenue_to_assets', 'st_debt_to_assets', 'lt_debt_to_assets',
|
||
'other_assets_ratio', 'goodwill_to_assets'
|
||
].includes(lowerCol)
|
||
|
||
// Special handling for Per Employee (Just number formatting, 2 decimals)
|
||
if (['revenue_per_employee', 'profit_per_employee'].includes(lowerCol)) {
|
||
if (numVal === undefined || numVal === null || isNaN(numVal)) return '-'
|
||
return numVal.toLocaleString('en-US', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
})
|
||
}
|
||
|
||
// Scale huge monetary values to "Yi" (100 Million)
|
||
// Bloomberg raw data is in Millions.
|
||
// Millions -> Yi (100 Millions) = divide by 100.
|
||
const isMoney = [
|
||
'revenue', 'net_income', 'operating_profit',
|
||
'total_assets', 'total_liabilities', 'equity', 'shareholders_equity', 'total_equity',
|
||
'cash', 'inventory', 'accounts¬es_receivable', 'receivables', 'accounts_payable', 'prepaid', 'prepayment',
|
||
'property_plant&equipment', 'fixed_assets', 'lt_investment', 'long_term_investments',
|
||
'st_debt', 'short_term_debt', 'lt_debt', 'long_term_debt', 'st_defer_rev', 'deferred_revenue', 'goodwill',
|
||
'cash_from_operating', 'capital_expenditure', 'capex', 'free_cash_flow', 'fcf',
|
||
'dividends_paid', 'repurchase',
|
||
'sg&a', 'selling&marketing', 'general&admin', 'r&d', 'depreciation',
|
||
'market_cap'
|
||
].includes(lowerCol)
|
||
|
||
if (isMoney) {
|
||
numVal = numVal / 100
|
||
}
|
||
|
||
if (isRatio) {
|
||
return numVal.toLocaleString('en-US', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
}) + '%'
|
||
}
|
||
|
||
// 如果大于 1000,不保留小数;否则保留两位小数
|
||
const isLarge = Math.abs(numVal) > 1000
|
||
return numVal.toLocaleString('en-US', {
|
||
minimumFractionDigits: isLarge ? 0 : 2,
|
||
maximumFractionDigits: isLarge ? 0 : 2
|
||
})
|
||
}
|
||
return String(value)
|
||
}
|