Files
SBrainCO/client/src/pages/BossPage.tsx
T
freedakgmail b2cdf352db fix: 修复老板驾驶舱与总部驾驶舱实收数据源不一致
BossPage核心指标行的优惠和实收从/store-expense/overview改为/overview API
- 优惠: ex.total_discount → od.discount
- 实收总额: ex.total_received → od.received
- totalRev(风险门店占比分母)同步改为od.received
现在两个驾驶舱的营业收入/优惠/实收完全一致
2026-08-11 19:58:22 +08:00

561 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
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, Target, ChevronDown, Volume2 } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { KPISection } from '@/components/KPISection'
import { BusinessReportDialog } from '@/components/BusinessReportDialog'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: any; opp: number; pct: number; confColor: string }) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`rounded-lg border transition-all ${expanded ? 'border-purple-300 shadow-sm' : ''}`}>
<div
className="flex cursor-pointer items-center gap-3 p-3"
onClick={() => setExpanded(!expanded)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-sm font-bold text-purple-600">{index + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{o.category}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${confColor}`}>{o.confidence}</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
线: {typeof o.baseline === 'number' ? (o.baseline < 100 ? formatPercent(o.baseline) : formatNumber(o.baseline)) : o.baseline}
{typeof o.baseline === 'number' && o.baseline < 100 ? '%' : ''} · : {o.owner} · : {o.evidence}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-green-600">{formatCurrency(opp)}</p>
<p className="text-[10px] text-muted-foreground"> {formatPercent(pct)}</p>
</div>
</div>
{expanded && o.detail && (
<div className="border-t bg-purple-50/20 px-4 py-3">
{o.detail.split('\n').map((line: string, idx: number) => {
const isHeader = line.endsWith('') || line.endsWith(':')
const isAction = line.startsWith('行动指向') || /^\d/.test(line)
const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入')
return (
<p
key={idx}
className={`text-xs leading-relaxed ${
isHeader ? 'mt-2 font-semibold text-foreground' :
isAction ? 'text-foreground' :
isStoreLine ? 'text-muted-foreground' :
'text-muted-foreground'
}`}
>
{line || '\u00A0'}
</p>
)
})}
</div>
)}
</div>
)
}
export function BossPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-04')
const [showReport, setShowReport] = useState(false)
const { data: overview, isLoading: odLoading } = useQuery({
queryKey: ['overview', month],
queryFn: () => api.get('/overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: daily, isLoading: dailyLoading } = useQuery({
queryKey: ['overview/daily', month],
queryFn: () => api.get('/overview/daily', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: expenseData, isLoading: exLoading } = useQuery({
queryKey: ['store-expense/overview', month],
queryFn: () => api.get('/store-expense/overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: waterfallData, isLoading: wfLoading } = useQuery({
queryKey: ['overview/profit-waterfall', month],
queryFn: () => api.get('/overview/profit-waterfall', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: riskData, isLoading: riskLoading } = useQuery({
queryKey: ['stores/risk', month],
queryFn: () => api.get('/stores/risk', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: priorityData, isLoading: priorityLoading } = useQuery({
queryKey: ['stores/priority', month],
queryFn: () => api.get('/stores/priority', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: costOverview, isLoading: costLoading } = useQuery({
queryKey: ['cost-analysis/store-overview', month],
queryFn: () => api.get('/cost-analysis/store-overview', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: expenseStructure, isLoading: structLoading } = useQuery({
queryKey: ['store-expense/expense-structure', month],
queryFn: () => api.get('/store-expense/expense-structure', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: profitRankingData } = useQuery({
queryKey: ['overview/store-profit-ranking', month],
queryFn: () => api.get('/overview/store-profit-ranking', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
const { data: profitOppData } = useQuery({
queryKey: ['overview/profit-opportunity', month],
queryFn: () => api.get('/overview/profit-opportunity', { params: { month } }),
staleTime: 5 * 60 * 1000,
})
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 profitRanking = (profitRankingData as any)?.data || []
const profitOpp = (profitOppData as any)?.data?.items || []
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Math.max(Number(o.opportunity || 0), 0), 0)
// 风险分布
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.consumption), type: 'total' },
{ name: '优惠', value: -Number(wf.discount), type: 'cost' },
{ name: '实收', value: Number(wf.received), type: 'subtotal' },
{ 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.store_contribution), type: 'profit' },
] : []
// 瀑布图累计计算
let cumulative = 0
const waterfallChart = waterfallSteps.map((step) => {
if (step.type === 'total') {
cumulative = step.value
return { name: step.name, base: 0, value: step.value, fill: '#3b82f6' }
} else if (step.type === 'subtotal') {
cumulative = step.value
return { name: step.name, base: 0, value: step.value, fill: '#6366f1' }
} else if (step.type === 'profit') {
return { name: step.name, base: 0, value: step.value, fill: '#22c55e' }
} else {
const decrease = Math.abs(step.value)
const base = cumulative - decrease
const result = { name: step.name, base, value: decrease, 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 justify-between">
<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"> · {month}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowReport(true)}
className="flex items-center gap-1.5 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-600 transition hover:bg-blue-100"
>
<Volume2 className="h-4 w-4" />
</button>
<MonthPicker month={month} onChange={setMonth} />
</div>
</div>
{/* ① 核心经营指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<MetricCard title="营业收入" value={od?.consumption} format="currency" description="全部门店消费金额合计" />
<MetricCard title="优惠" value={od?.discount} format="currency" description="各类优惠扣减合计" />
<MetricCard title="实收总额" value={od?.received} format="currency" trend={trendReceived} description={`全口径${ex?.total_stores}家门店。环比上周 ${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)}`} />
<MetricCard title="门店数" value={ex?.total_stores} unit="家" description={`盈利 ${ex?.profitable_stores}家 / 亏损 ${ex?.loss_stores}`} />
<MetricCard title="客单价" value={ex?.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(ex?.total_bills)}`} />
</div>
{/* ①b KPI达成率 */}
<KPISection month={month} level="hq" />
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
<ResponsiveContainer width="100%" height={280}>
<BarChart 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 labelFormatter={(label) => label} formatter={(v: any) => [formatCurrency(v), '金额']} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} shape={(props: any) => {
const { x, y, width, height, payload, fill } = props
if (!payload.value || payload.value === 0) return <rect />
const scale = height / payload.value
const newY = y - payload.base * scale
return <rect x={x} y={newY} width={width} height={height} fill={fill} rx={4} ry={4} />
}}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</BarChart>
</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.store_contribution)}</p>
</div>
</div>
{/* 成本费用占比汇总条 */}
{wf && (
<div className="mt-3 rounded-lg border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<div className="flex h-8 w-full overflow-hidden rounded-lg text-[10px] font-bold text-white">
<div className="flex items-center justify-center bg-orange-500" style={{ width: `${Number(wf.food_cost) / Number(wf.received) * 100}%` }} title={`食材 ${formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-blue-500" style={{ width: `${Number(wf.wage) / Number(wf.received) * 100}%` }} title={`人工 ${formatPercent(Number(wf.wage) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.wage) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-purple-500" style={{ width: `${Number(wf.rent) / Number(wf.received) * 100}%` }} title={`房租 ${formatPercent(Number(wf.rent) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.rent) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-cyan-500" style={{ width: `${Number(wf.utility) / Number(wf.received) * 100}%` }} title={`水电 ${formatPercent(Number(wf.utility) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.utility) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-slate-400" style={{ width: `${(Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100}%` }} title={`其他费用 ${formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}`}>
{formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.store_contribution) / Number(wf.received) * 100}%` }} title={`门店贡献利润 ${formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
<span> {formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)} 32%</span>
<span> {formatPercent(Number(wf.total_expense) / Number(wf.received) * 100)} 40%</span>
<span> {formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)} 10%</span>
</div>
</div>
)}
</CollapsibleSection>
)}
{/* ②b 利润机会池 */}
{profitOpp.length > 0 && (
<CollapsibleSection title="利润机会池" subtitle={`理论月度机会合计 ${formatCurrency(totalOpportunity)} · 30天承诺值 ≥ 150万元 · 点击展开详情`}>
<div className="space-y-2">
{[...profitOpp].sort((a: any, b: any) => Number(b.opportunity || 0) - Number(a.opportunity || 0)).map((o: any, i: number) => {
const opp = Number(o.opportunity || 0)
const pct = totalOpportunity > 0 ? (opp / totalOpportunity * 100) : 0
const confColor = o.confidence === '中高' ? 'text-green-600 bg-green-50' : o.confidence === '中' ? 'text-blue-600 bg-blue-50' : 'text-yellow-600 bg-yellow-50'
return (
<ProfitOppItem key={i} index={i} o={o} opp={opp} pct={pct} confColor={confColor} />
)
})}
</div>
<div className="mt-3 rounded-lg border border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">30</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{formatCurrency(totalOpportunity)} <span className="font-bold text-purple-600"> 150</span>
7.29% 10%
</p>
</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={80} labelLine={false} label={(e: any) => <tspan fontSize={11}>{e.name}</tspan>}>
{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>
{/* ⑤b 门店利润排名 */}
{profitRanking.length > 0 && (
<CollapsibleSection title="门店利润排名" subtitle="TOP5 贡献最高 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)">
<div className="grid gap-4 lg:grid-cols-2">
{/* TOP5 */}
<div>
<p className="mb-2 text-sm font-bold text-green-600"> TOP5</p>
<div className="space-y-2">
{profitRanking.slice(0, 5).map((s: any, i: number) => (
<div
key={s.store_code}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-green-200 bg-green-50/30 p-3 hover:bg-green-50/60"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<span className="text-lg font-bold text-green-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>
</div>
{/* BOTTOM5 */}
<div>
<p className="mb-2 text-sm font-bold text-red-600"> BOTTOM5</p>
<div className="space-y-2">
{profitRanking.filter((s: any) => Number(s.store_contribution) < 0).slice(-5).reverse().map((s: any, i: number) => (
<div
key={s.store_code}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-red-200 bg-red-50/30 p-3 hover:bg-red-50/60"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<span className="text-lg font-bold text-red-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>
</div>
</div>
</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>
{/* 本月经营态势弹窗 */}
<BusinessReportDialog
open={showReport}
onClose={() => setShowReport(false)}
data={{
month,
overview: od,
expense: ex,
waterfall: wf,
riskRows,
priorityRows,
costOverview: costOv,
profitOpp,
totalOpportunity: totalOpportunity,
trendReceived,
}}
/>
</div>
)
}