@@ -0,0 +1,329 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { BarChart , Bar , XAxis , YAxis , CartesianGrid , Tooltip , ResponsiveContainer , Cell , LineChart , Line , PieChart , Pie , Legend , ComposedChart } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency , formatNumber , formatPercent } from '@/lib/utils'
import { Crown , TrendingDown , AlertTriangle , TrendingUp , Building2 , Receipt } from 'lucide-react'
const RISK_COLORS : Record < string , string > = { '红色' : '#ef4444' , '黄色' : '#eab308' , '绿色' : '#22c55e' }
export function BossPage() {
const navigate = useNavigate ( )
const { data : overview , isLoading : odLoading } = useQuery ( {
queryKey : [ 'overview' ] ,
queryFn : ( ) = > api . get ( '/overview' ) ,
} )
const { data : daily , isLoading : dailyLoading } = useQuery ( {
queryKey : [ 'overview/daily' ] ,
queryFn : ( ) = > api . get ( '/overview/daily' ) ,
} )
const { data : expenseData , isLoading : exLoading } = useQuery ( {
queryKey : [ 'store-expense/overview' ] ,
queryFn : ( ) = > api . get ( '/store-expense/overview' ) ,
} )
const { data : waterfallData , isLoading : wfLoading } = useQuery ( {
queryKey : [ 'overview/profit-waterfall' ] ,
queryFn : ( ) = > api . get ( '/overview/profit-waterfall' ) ,
} )
const { data : riskData , isLoading : riskLoading } = useQuery ( {
queryKey : [ 'stores/risk' ] ,
queryFn : ( ) = > api . get ( '/stores/risk' ) ,
} )
const { data : priorityData , isLoading : priorityLoading } = useQuery ( {
queryKey : [ 'stores/priority' ] ,
queryFn : ( ) = > api . get ( '/stores/priority' ) ,
} )
const { data : costOverview , isLoading : costLoading } = useQuery ( {
queryKey : [ 'cost-analysis/store-overview' ] ,
queryFn : ( ) = > api . get ( '/cost-analysis/store-overview' ) ,
} )
const { data : expenseStructure , isLoading : structLoading } = useQuery ( {
queryKey : [ 'store-expense/expense-structure' ] ,
queryFn : ( ) = > api . get ( '/store-expense/expense-structure' ) ,
} )
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
const od = ( overview as any ) ? . data
const ex = ( expenseData as any ) ? . data
const wf = ( waterfallData as any ) ? . data
const riskRows = ( riskData as any ) ? . data || [ ]
const priorityRows = ( priorityData as any ) ? . data || [ ]
const dailyRows = ( daily as any ) ? . data || [ ]
const costOv = ( costOverview as any ) ? . data || { }
const structRows = ( expenseStructure as any ) ? . data || [ ]
// 风险分布
const riskSummary = riskRows . reduce ( ( acc : any , r : any ) = > {
acc [ r . risk_level ] = ( acc [ r . risk_level ] || 0 ) + 1
return acc
} , { } )
const riskRevSummary = riskRows . reduce ( ( acc : any , r : any ) = > {
if ( ! acc [ r . risk_level ] ) acc [ r . risk_level ] = 0
acc [ r . risk_level ] += Number ( r . received || 0 )
return acc
} , { } )
const totalStores = riskRows . length
const totalRev = Number ( od ? . received || 0 )
// P0/P1门店
const p0Stores = priorityRows . filter ( ( s : any ) = > s . action_priority ? . startsWith ( 'P0' ) )
const p1Stores = priorityRows . filter ( ( s : any ) = > s . action_priority ? . startsWith ( 'P1' ) )
const p0p1Stores = [ . . . p0Stores , . . . p1Stores ]
// 营收TOP5 / BOTTOM5
const sortedByRev = [ . . . riskRows ] . sort ( ( a : any , b : any ) = > Number ( b . received || 0 ) - Number ( a . received || 0 ) )
const top5 = sortedByRev . slice ( 0 , 5 )
const bottom5 = sortedByRev . filter ( ( r : any ) = > Number ( r . received ) > 0 ) . slice ( - 5 ) . reverse ( )
const rankingData = [ . . . top5 , . . . bottom5 ] . map ( ( r : any , i : number ) = > ( {
name : r.store_name?.substring ( 0 , 6 ) || '' ,
value : Number ( r . received || 0 ) ,
code : r.store_code ,
isTop : i < 5 ,
} ) )
// 利润瀑布数据
const waterfallSteps = wf ? [
{ name : '实收' , value : Number ( wf . received ) , type : 'total' } ,
{ name : '食材成本' , value : - Number ( wf . food_cost ) , type : 'cost' } ,
{ name : '人工' , value : - Number ( wf . wage ) , type : 'cost' } ,
{ name : '房租' , value : - Number ( wf . rent ) , type : 'cost' } ,
{ name : '水电' , value : - Number ( wf . utility ) , type : 'cost' } ,
{ name : '宿舍' , value : - Number ( wf . dorm ) , type : 'cost' } ,
{ name : '外卖佣金' , value : - Number ( wf . commission ) , type : 'cost' } ,
{ name : '其他费用' , value : - Number ( wf . other_expense ) , type : 'cost' } ,
{ name : '净利润' , value : Number ( wf . net_profit ) , type : 'profit' } ,
] : [ ]
// 瀑布图累计计算
let cumulative = 0
const waterfallChart = waterfallSteps . map ( ( step ) = > {
if ( step . type === 'total' || step . type === 'profit' ) {
cumulative = step . value
return { name : step.name , base : 0 , value : step.value , fill : step.type === 'profit' ? '#22c55e' : '#3b82f6' }
} else {
const base = cumulative + step . value
const result = { name : step.name , base , value : - step . value , fill : '#ef4444' }
cumulative = base
return result
}
} )
// 费用结构饼图
const pieData = structRows . map ( ( r : any ) = > ( { name : r.account_name?.length > 6 ? r . account_name . substring ( 0 , 6 ) + '…' : r . account_name , value : parseFloat ( r . amount ) } ) )
const PIE_COLORS = [ '#3b82f6' , '#22c55e' , '#f97316' , '#eab308' , '#a855f7' , '#ec4899' , '#06b6d4' , '#64748b' ]
// 日度趋势
const last7 = dailyRows . slice ( - 7 )
const prev7 = dailyRows . slice ( - 14 , - 7 )
const sumKey = ( arr : any [ ] , key : string ) = > arr . reduce ( ( s , r ) = > s + Number ( r [ key ] || 0 ) , 0 )
const trend = ( curr : number , prev : number ) = > prev > 0 ? Math . round ( ( curr - prev ) / prev * 1000 ) / 10 : 0
const trendReceived = trend ( sumKey ( last7 , 'received' ) , sumKey ( prev7 , 'received' ) )
const trendBills = trend ( sumKey ( last7 , 'bill_count' ) , sumKey ( prev7 , 'bill_count' ) )
// 成本超耗TOP5
const costRed = Number ( costOv . red_count || 0 )
const costOrange = Number ( costOv . orange_count || 0 )
const costGreen = Number ( costOv . green_count || 0 )
if ( pageLoading ) {
return < LoadingSpinner text = "加载老板驾驶舱..." / >
}
return (
< div className = "space-y-4" >
{ /* 标题 */ }
< div className = "flex items-center gap-2" >
< Crown className = "text-yellow-500" size = { 24 } / >
< div >
< h1 className = "text-xl font-bold" > 老 板 驾 驶 舱 < / h1 >
< p className = "mt-0.5 text-xs text-muted-foreground" > 经 营 全 景 · 2026 年 4 月 < / p >
< / div >
< / div >
{ /* ① 核心经营指标 */ }
< div className = "grid grid-cols-2 gap-3 md:grid-cols-4" >
< MetricCard title = "实收总额" value = { od ? . received } format = "currency" trend = { trendReceived } description = { ` 公式:全部门店实收合计。环比上周 ${ trendReceived > 0 ? '↑' : '↓' } ${ Math . abs ( trendReceived ) } % ` } / >
< MetricCard title = "实际净利润" value = { ex ? . actual_net_profit } format = "currency" status = { Number ( ex ? . actual_net_margin_pct ) < 5 ? 'bad' : Number ( ex ? . actual_net_margin_pct ) < 10 ? 'warn' : 'good' } description = { ` 净利率 ${ formatPercent ( ex ? . actual_net_margin_pct ) } · 公式:实收 - 食材成本 - 经营费用。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改 ` } / >
< MetricCard title = "门店数" value = { totalStores } unit = "家" description = { ` 盈利 ${ ex ? . profitable_stores } 家 / 亏损 ${ ex ? . loss_stores } 家。无费用数据 ${ ex ? . no_expense_stores } 家 ` } / >
< MetricCard title = "客单价" value = { od ? . avg_bill_value } format = "currency" trend = { trend ( ( last7 . length > 0 ? sumKey ( last7 , 'received' ) / sumKey ( last7 , 'bill_count' ) : 0 ) , ( prev7 . length > 0 ? sumKey ( prev7 , 'received' ) / sumKey ( prev7 , 'bill_count' ) : 0 ) ) } description = { ` 账单 ${ formatNumber ( od ? . bill_count ) } 笔 · 公式:实收 ÷ 账单数 ` } / >
< / div >
{ /* ② 利润瀑布 */ }
{ wf && (
< CollapsibleSection title = "利润结构" subtitle = "实收 → 减各项成本费用 → 净利润" >
< ResponsiveContainer width = "100%" height = { 280 } >
< ComposedChart data = { waterfallChart } margin = { { top : 10 , right : 10 , left : 0 , bottom : 0 } } >
< CartesianGrid strokeDasharray = "3 3" / >
< XAxis dataKey = "name" tick = { { fontSize : 10 } } angle = { - 20 } textAnchor = "middle" height = { 60 } / >
< YAxis tickFormatter = { ( v ) = > v >= 10000 ? ` ${ ( v / 10000 ) . toFixed ( 0 ) } 万 ` : v } tick = { { fontSize : 10 } } / >
< Tooltip formatter = { ( v : any ) = > formatCurrency ( v ) } / >
< Bar dataKey = "base" stackId = "a" fill = "transparent" / >
< Bar dataKey = "value" stackId = "a" radius = { [ 4 , 4 , 0 , 0 ] } >
{ waterfallChart . map ( ( entry , i ) = > < Cell key = { i } fill = { entry . fill } / > ) }
< / Bar >
< / ComposedChart >
< / ResponsiveContainer >
< div className = "mt-2 grid grid-cols-3 gap-2 md:grid-cols-5" >
{ waterfallSteps . filter ( s = > s . type === 'cost' ) . map ( s = > (
< div key = { s . name } className = "rounded border p-2 text-center" >
< p className = "text-xs text-muted-foreground" > { s . name } < / p >
< p className = "text-sm font-bold text-red-600" > { formatCurrency ( Math . abs ( s . value ) ) } < / p >
< / div >
) ) }
< div className = "rounded border border-green-200 bg-green-50/40 p-2 text-center" >
< p className = "text-xs text-muted-foreground" > 净 利 润 < / p >
< p className = "text-sm font-bold text-green-700" > { formatCurrency ( wf . net_profit ) } < / p >
< / div >
< / div >
< / CollapsibleSection >
) }
{ /* ③ 风险态势 */ }
< div className = "grid gap-4 lg:grid-cols-3" >
< div className = { ` rounded-lg border p-4 ${ riskSummary [ '红色' ] > 15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40' } ` } >
< div className = "flex items-center gap-2" >
< AlertTriangle className = "text-red-500" size = { 20 } / >
< span className = "text-sm font-bold" > 红 色 门 店 < / span >
< / div >
< p className = "mt-2 text-3xl font-bold text-red-600" > { riskSummary [ '红色' ] || 0 } < span className = "text-base text-muted-foreground" > 家 < / span > < / p >
< p className = "mt-1 text-xs text-muted-foreground" > 实 收 { formatCurrency ( riskRevSummary [ '红色' ] ) } · 占 比 { totalRev > 0 ? formatPercent ( riskRevSummary [ '红色' ] / totalRev * 100 ) : '-' } < / p >
< / div >
< div className = "rounded-lg border border-yellow-200 bg-yellow-50/40 p-4" >
< div className = "flex items-center gap-2" >
< TrendingDown className = "text-yellow-500" size = { 20 } / >
< span className = "text-sm font-bold" > 黄 色 门 店 < / span >
< / div >
< p className = "mt-2 text-3xl font-bold text-yellow-600" > { riskSummary [ '黄色' ] || 0 } < span className = "text-base text-muted-foreground" > 家 < / span > < / p >
< p className = "mt-1 text-xs text-muted-foreground" > 实 收 { formatCurrency ( riskRevSummary [ '黄色' ] ) } · 占 比 { totalRev > 0 ? formatPercent ( riskRevSummary [ '黄色' ] / totalRev * 100 ) : '-' } < / p >
< / div >
< div className = "rounded-lg border border-green-200 bg-green-50/40 p-4" >
< div className = "flex items-center gap-2" >
< TrendingUp className = "text-green-500" size = { 20 } / >
< span className = "text-sm font-bold" > 绿 色 门 店 < / span >
< / div >
< p className = "mt-2 text-3xl font-bold text-green-600" > { riskSummary [ '绿色' ] || 0 } < span className = "text-base text-muted-foreground" > 家 < / span > < / p >
< p className = "mt-1 text-xs text-muted-foreground" > 实 收 { formatCurrency ( riskRevSummary [ '绿色' ] ) } · 占 比 { totalRev > 0 ? formatPercent ( riskRevSummary [ '绿色' ] / totalRev * 100 ) : '-' } < / p >
< / div >
< / div >
{ /* P0/P1 门店清单 */ }
{ p0p1Stores . length > 0 && (
< CollapsibleSection title = { ` 重点整改门店 (P0 ${ p0Stores . length } 家 + P1 ${ p1Stores . length } 家) ` } subtitle = "需紧急修复和重点整改的门店" >
< div className = "overflow-auto" >
< table className = "w-full text-xs" >
< thead className = "bg-muted" >
< tr >
< th className = "text-left p-2" > 门 店 < / th >
< th className = "text-right p-2" > 实 收 < / th >
< th className = "text-center p-2" > 优 先 级 < / th >
< th className = "text-center p-2" > 风 险 < / th >
< th className = "text-left p-2" > 问 题 < / th >
< / tr >
< / thead >
< tbody >
{ p0p1Stores . map ( ( s : any ) = > (
< tr
key = { s . store_code }
className = "cursor-pointer border-b hover:bg-muted/50"
onClick = { ( ) = > navigate ( ` /stores/ ${ s . store_code } ` ) }
>
< td className = "p-2 font-medium" > { s . store_name } < / td >
< td className = "text-right p-2" > { formatCurrency ( s . received ) } < / td >
< td className = "text-center p-2" >
< span className = { ` rounded px-1.5 py-0.5 text-[10px] font-bold ${ s . action_priority ? . startsWith ( 'P0' ) ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700' } ` } >
{ s . action_priority }
< / span >
< / td >
< td className = "text-center p-2" >
< span className = { ` rounded px-1.5 py-0.5 text-[10px] font-bold ${ s . risk_level === '红色' ? 'bg-red-100 text-red-700' : s . risk_level === '黄色' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700' } ` } >
{ s . risk_level }
< / span >
< / td >
< td className = "p-2 text-muted-foreground" > { s . problem_combination || '-' } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
< / CollapsibleSection >
) }
{ /* ④ 成本异常 + 费用结构 */ }
< div className = "grid gap-4 lg:grid-cols-2" >
< CollapsibleSection title = "成本异常分布" subtitle = "食材成本超耗情况" >
< div className = "grid grid-cols-3 gap-3" >
< div className = "rounded-lg border border-red-200 bg-red-50/40 p-3 text-center" >
< p className = "text-xs text-muted-foreground" > 严 重 超 耗 < / p >
< p className = "text-2xl font-bold text-red-600" > { costRed } < / p >
< p className = "text-xs text-muted-foreground" > 家 < / p >
< / div >
< div className = "rounded-lg border border-orange-200 bg-orange-50/40 p-3 text-center" >
< p className = "text-xs text-muted-foreground" > 明 显 超 耗 < / p >
< p className = "text-2xl font-bold text-orange-600" > { costOrange } < / p >
< p className = "text-xs text-muted-foreground" > 家 < / p >
< / div >
< div className = "rounded-lg border border-green-200 bg-green-50/40 p-3 text-center" >
< p className = "text-xs text-muted-foreground" > 基 本 正 常 < / p >
< p className = "text-2xl font-bold text-green-600" > { costGreen } < / p >
< p className = "text-xs text-muted-foreground" > 家 < / p >
< / div >
< / div >
< div className = "mt-3 rounded border p-3" >
< p className = "text-xs text-muted-foreground" > 总 差 异 金 额 < / p >
< p className = "text-xl font-bold text-red-600" > { formatCurrency ( costOv . total_variance ) } < / p >
< p className = "mt-1 text-xs text-muted-foreground" > 公 式 : 实 际 食 材 成 本 - 理 论 食 材 成 本 。 正 值 表 示 超 耗 < / p >
< / div >
< / CollapsibleSection >
< CollapsibleSection title = "费用结构" subtitle = "各费用科目占比" >
{ ! structLoading && pieData . length > 0 && (
< ResponsiveContainer width = "100%" height = { 280 } >
< PieChart >
< Pie data = { pieData } dataKey = "value" nameKey = "name" cx = "50%" cy = "50%" outerRadius = { 100 } label = { ( e : any ) = > ` ${ e . name } ` } >
{ pieData . map ( ( _ : any , i : number ) = > < Cell key = { i } fill = { PIE_COLORS [ i % PIE_COLORS . length ] } / > ) }
< / Pie >
< Tooltip formatter = { ( v : any ) = > formatCurrency ( v ) } / >
< Legend wrapperStyle = { { fontSize : 10 } } / >
< / PieChart >
< / ResponsiveContainer >
) }
< / CollapsibleSection >
< / div >
{ /* ⑤ 门店营收排名 */ }
< CollapsibleSection title = "门店营收排名" subtitle = "TOP5 赚钱最多 · BOTTOM5 需关注" >
< ResponsiveContainer width = "100%" height = { 300 } >
< BarChart data = { rankingData } layout = "vertical" margin = { { left : 80 } } >
< CartesianGrid strokeDasharray = "3 3" / >
< XAxis type = "number" tickFormatter = { ( v ) = > v >= 10000 ? ` ${ ( v / 10000 ) . toFixed ( 0 ) } 万 ` : v } tick = { { fontSize : 10 } } / >
< YAxis type = "category" dataKey = "name" tick = { { fontSize : 10 } } width = { 80 } / >
< Tooltip formatter = { ( v : any ) = > formatCurrency ( v ) } / >
< Bar dataKey = "value" radius = { [ 0 , 4 , 4 , 0 ] } onClick = { ( d : any ) = > d . code && navigate ( ` /stores/ ${ d . code } ` ) } >
{ rankingData . map ( ( entry , i ) = > (
< Cell key = { i } fill = { entry . isTop ? '#22c55e' : '#ef4444' } className = "cursor-pointer" / >
) ) }
< / Bar >
< / BarChart >
< / ResponsiveContainer >
< / CollapsibleSection >
{ /* ⑥ 日度实收趋势 */ }
< CollapsibleSection title = "日度实收趋势" subtitle = { ` 近30天 · 环比上周 ${ trendReceived > 0 ? '↑' : '↓' } ${ Math . abs ( trendReceived ) } % ` } >
< ResponsiveContainer width = "100%" height = { 220 } >
< LineChart data = { dailyRows } >
< CartesianGrid strokeDasharray = "3 3" / >
< XAxis dataKey = "business_date" tickFormatter = { ( v ) = > v ? . substring ( 5 , 10 ) } tick = { { fontSize : 10 } } / >
< YAxis tickFormatter = { ( v ) = > v >= 10000 ? ` ${ ( v / 10000 ) . toFixed ( 0 ) } 万 ` : v } tick = { { fontSize : 10 } } / >
< Tooltip labelFormatter = { ( v ) = > v ? . substring ( 0 , 10 ) } formatter = { ( v : any ) = > formatCurrency ( v ) } / >
< Line type = "monotone" dataKey = "received" stroke = "#3b82f6" name = "实收" dot = { false } strokeWidth = { 2 } / >
< / LineChart >
< / ResponsiveContainer >
< / CollapsibleSection >
< / div >
)
}