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:
+475
-10
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user