feat: 统一SearchSelect下拉组件 + 修复营销方案数据 + 平台经济性增强

- 创建SearchSelect搜索下拉组件,选项≤8自动隐藏搜索框
- 全站替换原生select为SearchSelect(13处下拉)
- 修复/revenue/store-ranking优惠率字段名不匹配(discount_rate_pct→avg_discount_rate_pct)
- RevenuePage门店营收明细改用FilterableTable支持筛选排序分页
- 修复/marketing/plans按门店分组改为按marketing_plan分组
- 营销方案分析补充消费额、优惠额列和Top5概览卡片
- PlatformPage门店平台经济性增加门店搜索、经济性优列(平台分色)
- 平台成本率算法说明显示在标题下方
This commit is contained in:
freedakgmail
2026-08-01 23:08:34 +08:00
parent d724a565f5
commit ed7beb7e47
20 changed files with 1035 additions and 286 deletions
+475 -10
View File
@@ -1,18 +1,44 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { MetricCard } from '@/components/MetricCard'
import { FilterableTable } from '@/components/FilterableTable'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, PieChart, Pie, Legend } from 'recharts'
import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
import { useState } from 'react'
const SCORE_DIMENSIONS = [
{ key: 'score_revenue', label: '营收规模', max: 20 },
{ key: 'score_cost', label: '成本控制', max: 20 },
{ key: 'score_margin', label: '毛利率', max: 15 },
{ key: 'score_risk', label: '风险等级', max: 10 },
{ key: 'score_repeat', label: '复购率', max: 10 },
{ key: 'score_member', label: '会员占比', max: 10 },
{ key: 'score_task', label: '任务完成', max: 10 },
{ key: 'score_bill', label: '客单价', max: 5 },
]
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
const TABS: { key: DetailTab; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'meal', label: '餐段分析' },
{ key: 'category', label: '品类结构' },
{ key: 'cost', label: '成本分析' },
{ key: 'member', label: '会员分析' },
{ key: 'anomaly', label: '异常账单' },
]
export function StorePage() {
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const [month, setMonth] = useState('2026-04')
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list', month],
@@ -55,6 +81,47 @@ export function StorePage() {
queryFn: () => api.get('/sku/abc', { params: { month } }),
})
// StoreDetailPage queries
const { data: storeDetailData } = useQuery({
queryKey: ['store-detail', storeCode, month],
queryFn: () => api.get(`/stores/${storeCode}`, { params: { month } }),
})
const { data: healthData } = useQuery({
queryKey: ['store-health', storeCode, month],
queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }),
})
const { data: mealData } = useQuery({
queryKey: ['store', storeCode, 'meal-period', month],
queryFn: () => api.get(`/stores/${storeCode}/meal-period`, { params: { month } }),
enabled: detailTab === 'meal',
})
const { data: catData } = useQuery({
queryKey: ['store', storeCode, 'category-mix', month],
queryFn: () => api.get(`/stores/${storeCode}/category-mix`, { params: { month } }),
enabled: detailTab === 'category',
})
const { data: costData } = useQuery({
queryKey: ['store', storeCode, 'cost', month],
queryFn: () => api.get(`/stores/${storeCode}/cost`, { params: { month } }),
enabled: detailTab === 'cost',
})
const { data: memberData } = useQuery({
queryKey: ['store', storeCode, 'member', month],
queryFn: () => api.get(`/stores/${storeCode}/member`, { params: { month } }),
enabled: detailTab === 'member',
})
const { data: anomalyDetailData } = useQuery({
queryKey: ['store', storeCode, 'anomalies', month],
queryFn: () => api.get(`/stores/${storeCode}/anomalies`, { params: { month, page_size: 200 } }),
enabled: detailTab === 'anomaly',
})
const card = (cardData as any)?.data
const anomalies = card?.anomalies || []
const tasks = (tasksData as any)?.data || []
@@ -68,6 +135,55 @@ export function StorePage() {
const priorityRows = (priorityData as any)?.data || []
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
// StoreDetailPage derived data
const sd = (storeDetailData as any)?.data
const healthRows = ((healthData as any)?.data) || []
const health = healthRows.find((r: any) => r.store_code === storeCode)
const mealRows = ((mealData as any)?.data) || []
const cat = (catData as any)?.data
const cost = (costData as any)?.data
const member = (memberData as any)?.data
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
const sc = sd?.scorecard
const action = sd?.action
const risk = sd?.risk
const riskDesc: Record<string, { color: string; desc: string }> = {
'绿色': { color: 'text-green-600 bg-green-50 border-green-200', desc: '各项指标健康,经营状态良好,维持现有运营策略即可。' },
'黄色': { color: 'text-yellow-600 bg-yellow-50 border-yellow-200', desc: '存在单项指标偏差,需关注并针对性改善,避免风险升级。' },
'红色': { color: 'text-red-600 bg-red-50 border-red-200', desc: '多项指标异常,经营风险较高,需立即介入并制定整改计划。' },
}
const quadrantDesc: Record<string, string> = {
'明星门店': '高营收高毛利,是公司的核心利润来源。应总结其成功经验并推广至其他门店。',
'现金牛门店': '高营收但毛利偏低,通过成本优化有较大利润提升空间。',
'潜力门店': '营收偏低但毛利较好,具备增长潜力,需提升客流和营收规模。',
'问题门店': '营收和毛利均偏低,需全面诊断并考虑调整经营策略或选址。',
}
const riskInfo = risk?.risk_level ? riskDesc[risk.risk_level] : null
const quadrant = action?.management_quadrant || ''
const quadrantInfo = quadrantDesc[quadrant]
const companyAvg = { daily_rev: 20144, bill: 36.8, discount: 20.5, margin: 71.5, member: 15.9, repeat: 37.0, delivery: 36.1, combo: 20.6, items: 4.05 }
const deviations: { label: string; store: number; company: number; unit: string; good: 'high' | 'low' }[] = [
{ label: '日均营收', store: Number(risk?.avg_daily_received) || 0, company: companyAvg.daily_rev, unit: '元', good: 'high' },
{ label: '客单价', store: Number(risk?.avg_bill_value) || 0, company: companyAvg.bill, unit: '元', good: 'high' },
{ label: '优惠率', store: Number(risk?.discount_rate_pct) || 0, company: companyAvg.discount, unit: '%', good: 'low' },
{ label: '毛利率', store: Number(risk?.theoretical_margin_pct) || 0, company: companyAvg.margin, unit: '%', good: 'high' },
{ label: '会员占比', store: Number(risk?.member_bill_share_pct) || 0, company: companyAvg.member, unit: '%', good: 'high' },
{ label: '复购率', store: Number(action?.repeat_rate_pct) || 0, company: companyAvg.repeat, unit: '%', good: 'high' },
{ label: '外卖占比', store: Number(action?.delivery_bill_share_pct) || 0, company: companyAvg.delivery, unit: '%', good: 'low' },
{ label: '成本差异率', store: Number(action?.variance_to_theoretical_pct) || 0, company: 20, unit: '%', good: 'low' },
]
const problemDeviations = deviations.filter(d => {
const diff = d.store - d.company
return d.good === 'high' ? diff < 0 : diff > 0
})
const storeInfo = stores.find((s: any) => s.code === storeCode)
const storeName = storeInfo?.name || ''
const primaryIssue = storeInfo?.issue || priorityInfo?.problem_combination || '-'
@@ -113,15 +229,13 @@ export function StorePage() {
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<select
<SearchSelect
value={storeCode}
onChange={(e) => setStoreCode(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
>
{stores.map((s: any) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
onChange={setStoreCode}
options={stores.map((s: any) => ({ value: s.code, label: s.name }))}
emptyLabel="选择门店"
className="min-w-[160px]"
/>
</div>
</div>
@@ -392,6 +506,357 @@ export function StorePage() {
</div>
</div>
)}
{/* ========== 门店详情区 ========== */}
{sc && Number(sc.received) > 0 && (
<>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<MetricCard title="实收" value={sc.received} format="currency" />
<MetricCard title="账单数" value={sc.bill_count} format="number" />
<MetricCard title="客单价" value={sc.avg_bill_value} format="currency" />
<MetricCard title="优惠率" value={sc.discount_rate_pct} format="percent" />
<MetricCard title="理论毛利率" value={sc.theoretical_margin_pct} format="percent" />
<MetricCard title="会员占比" value={sc.member_bill_share_pct} format="percent" />
</div>
{/* 风险与经营状态说明 */}
<div className="grid gap-3 md:grid-cols-2">
{riskInfo && (
<div className={`rounded-lg border p-3 ${riskInfo.color}`}>
<div className="flex items-center gap-2">
<span className="font-bold">{risk.risk_level}</span>
{action?.problem_combination && <span className="text-xs opacity-80"> {action.problem_combination}</span>}
</div>
<p className="mt-1 text-sm opacity-90">{riskInfo.desc}</p>
{problemDeviations.length > 0 && (
<div className="mt-2 space-y-1 border-t border-current/20 pt-2">
<p className="text-xs font-medium opacity-80">vs </p>
{problemDeviations.map(d => {
const diff = d.store - d.company
const diffStr = diff > 0 ? `+${diff.toFixed(1)}${d.unit}` : `${diff.toFixed(1)}${d.unit}`
return (
<div key={d.label} className="flex items-center justify-between text-xs">
<span className="opacity-80">{d.label}</span>
<span className="font-medium">{d.store.toFixed(1)}{d.unit} <span className="opacity-60">vs {d.company}{d.unit}</span> <span className="font-bold">{diffStr}</span></span>
</div>
)
})}
</div>
)}
</div>
)}
{quadrantInfo && (
<div className="rounded-lg border p-3 bg-blue-50 border-blue-200 text-blue-700">
<div className="flex items-center gap-2">
<span className="font-bold">{quadrant}</span>
{action?.scale_tier && <span className="text-xs opacity-80"> {action.scale_tier}</span>}
</div>
<p className="mt-1 text-sm opacity-90">{quadrantInfo}</p>
<div className="mt-2 space-y-1 border-t border-blue-200/50 pt-2">
<p className="text-xs font-medium opacity-80"></p>
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(risk?.avg_daily_received || 0).toFixed(0)} <span className="opacity-60">vs 18642</span></span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(risk?.theoretical_margin_pct || 0).toFixed(1)}% <span className="opacity-60">vs 70.0%</span></span>
</div>
{action?.benchmark_score && (
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(action.benchmark_score).toFixed(1)}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* 行动建议 */}
{action?.action_priority && (
<div className="rounded-lg border p-3 bg-muted/50">
<div className="flex flex-wrap items-center gap-3">
<span className="font-bold text-sm">{action.action_priority}</span>
{action.problem_combination && <span className="text-sm text-muted-foreground">{action.problem_combination}</span>}
{action.business_type && <span className="text-xs rounded bg-muted px-2 py-0.5">{action.business_type}</span>}
{action?.variance_level && (
<span className={`text-xs rounded px-2 py-0.5 ${action.variance_level.includes('红色') ? 'bg-red-100 text-red-700' : action.variance_level.includes('黄色') ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
{action.variance_to_theoretical_pct}% ({action.variance_level})
</span>
)}
</div>
</div>
)}
{/* Tab bar */}
<div className="flex gap-1 border-b">
{TABS.map(t => (
<button key={t.key} onClick={() => setDetailTab(t.key)} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${detailTab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
{t.label}
</button>
))}
</div>
{/* 概览 Tab */}
{detailTab === 'overview' && (
<div className="space-y-4">
{health && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid gap-4 md:grid-cols-2">
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={SCORE_DIMENSIONS.map(d => ({ dimension: d.label, score: Math.round(Number(health[d.key] || 0) / d.max * 100), fullMark: 100 }))}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" stroke={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
<div className="space-y-2">
{SCORE_DIMENSIONS.map(d => {
const score = Number(health[d.key] || 0)
const pct = (score / d.max) * 100
const color = pct >= 75 ? '#22c55e' : pct >= 50 ? '#eab308' : '#ef4444'
return (
<div key={d.key}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{d.label}</span>
<span className="text-muted-foreground">{score.toFixed(1)} / {d.max}</span>
</div>
<div className="mt-0.5 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{sd?.platform && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
</div>
</div>
)}
</div>
)}
{/* 餐段分析 Tab */}
{detailTab === 'meal' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{mealRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={mealRows}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="meal_period" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : n === '平均单价' ? formatCurrency(v) : v} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="received" name="实收" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="avg_bill" name="平均单价" fill="#eab308" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={mealRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill', label: '平均单价' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill', label: '平均单价', align: 'right', render: (r) => formatCurrency(r.avg_bill) },
{ key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) },
{ key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => <span className={Number(r.vs_network_pct) < 0 ? 'text-red-600' : 'text-green-600'}>{Number(r.vs_network_pct).toFixed(1)}%</span> },
]}
/>
</div>
</>
)}
</div>
)}
{/* 品类结构 Tab */}
{detailTab === 'category' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{!cat?.store ? <p className="text-sm text-muted-foreground"></p> : (
(() => {
const s = cat.store
const c = cat.company
const catData = [
{ name: '兰州拉面', store: Number(s.lanzhou_noodle || 0), company: Number(c.total_noodle || 0) },
{ name: '西式简餐', store: Number(s.western_staple || 0), company: Number(c.total_western || 0) },
{ name: '外卖套餐', store: Number(s.delivery_package || 0), company: Number(c.total_delivery || 0) },
{ name: '冷菜', store: Number(s.cold_dishes || 0), company: Number(c.total_cold || 0) },
{ name: '丝路美食', store: Number(s.silk_road_food || 0), company: Number(c.total_silk || 0) },
]
const totalStore = catData.reduce((sum, d) => sum + d.store, 0)
const totalCompany = catData.reduce((sum, d) => sum + d.company, 0)
const pieData = catData.map(d => ({ name: d.name, value: totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, fill: ['#3b82f6', '#eab308', '#22c55e', '#a855f7', '#f97316'][catData.indexOf(d)] }))
return (
<>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }: any) => `${name} ${value}%`}>
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">vs </p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={catData.map(d => ({ name: d.name, '门店占比': totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, '公司占比': totalCompany > 0 ? Math.round(d.company / totalCompany * 1000) / 10 : 0 }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis unit="%" tick={{ fontSize: 10 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar dataKey="门店占比" fill="#3b82f6" />
<Bar dataKey="公司占比" fill="#e2e8f0" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="主力品类" value={s.top_category || '-'} format="text" />
<MetricCard title="主力品类占比" value={s.top_category_share_pct} format="percent" />
<MetricCard title="面食占比" value={s.noodle_share_pct} format="percent" />
<MetricCard title="外卖占比" value={s.delivery_package_share_pct} format="percent" />
</div>
</>
)
})()
)}
</div>
)}
{/* 成本分析 Tab */}
{detailTab === 'cost' && (
<div className="space-y-4">
{cost?.cost ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="理论成本率" value={cost.cost.theoretical_cost_rate_pct} format="percent" />
<MetricCard title="实际成本率" value={cost.cost.actual_food_cost_rate_pct} format="percent" />
<MetricCard title="差异率" value={cost.cost.variance_to_theoretical_pct} format="percent" />
<MetricCard title="差异等级" value={cost.cost.variance_level} format="text" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{cost.categories?.length > 0 ? (
<FilterableTable
data={cost.categories}
filterKey="category_name"
filterLabel="全部分类"
sortOptions={[
{ key: 'consumption_amount', label: '消耗金额' },
{ key: 'actual_cost_rate_pct', label: '实际占比' },
{ key: 'benchmark_rate_pct', label: '公司基准' },
{ key: 'variance_pct', label: '差异' },
]}
defaultSort="consumption_amount"
columns={[
{ key: 'category_name', label: '原料分类' },
{ key: 'consumption_amount', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_amount) },
{ key: 'actual_cost_rate_pct', label: '实际占比', align: 'right', render: (r) => formatPercent(r.actual_cost_rate_pct) },
{ key: 'benchmark_rate_pct', label: '公司基准', align: 'right', render: (r) => formatPercent(r.benchmark_rate_pct) },
{ key: 'variance_pct', label: '差异', align: 'right', render: (r) => <span className={Number(r.variance_pct) > 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}%</span> },
]}
/>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 会员分析 Tab */}
{detailTab === 'member' && (
<div className="space-y-4">
{member?.opportunity ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="会员占比" value={member.opportunity.member_share_pct} format="percent" />
<MetricCard title="公司均值" value={member.opportunity.company_member_share_pct} format="percent" />
<MetricCard title="复购率" value={member.repeat?.repeat_rate_pct} format="percent" />
<MetricCard title="复购收入占比" value={member.repeat?.repeat_revenue_share_pct} format="percent" />
</div>
{member.monthly && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活跃会员数" value={member.monthly.member_count} format="number" />
<MetricCard title="总订单数" value={member.monthly.total_orders} format="number" />
<MetricCard title="人均订单" value={member.monthly.avg_orders ? Number(member.monthly.avg_orders).toFixed(1) : '-'} format="text" />
<MetricCard title="人均消费" value={member.monthly.avg_received} format="currency" />
</div>
</div>
)}
{member.opportunity.conversion_bill_scenario && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground">{member.opportunity.conversion_bill_scenario}</p>
<p className="mt-1 text-sm text-muted-foreground">{member.opportunity.revenue_uplift_scenario}</p>
</div>
)}
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 异常账单 Tab */}
{detailTab === 'anomaly' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({anomalyDetailTotal})</h2>
{anomalyDetailRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<FilterableTable
data={anomalyDetailRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
</div>
)}
</>
)}
</div>
)
}