271ad8041d
- 后端: 所有API的discount改为从bill_fact累计discount_total - 后端: revenue/daily-summary增加consumption字段 - 后端: situational-awareness/forecast修复c175字符串比较为timestamp - 后端: region/comparison去掉未知区域排除,统一口径 - 后端: daily-card增加营业收入/优惠指标,返回target_date/baseline_date - 前端: BossPage/DashboardPage/BankPage/StorePage/ExpenseOverviewTab/RegionalPage/RegionComparisonPage/RevenuePage 均增加营业收入和优惠指标卡 - 前端: StorePage日均经营概览改为最新经营日概览,显示具体对比日期 - 前端: 日期格式统一截取YYYY-MM-DD显示
879 lines
49 KiB
TypeScript
879 lines
49 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import api from '@/lib/api'
|
||
import { Badge } from '@/components/Badge'
|
||
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 { KPISection } from '@/components/KPISection'
|
||
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],
|
||
queryFn: () => api.get('/stores/risk', { params: { month } }),
|
||
})
|
||
const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue }))
|
||
|
||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||
queryKey: ['stores-priority-detail', month],
|
||
queryFn: () => api.get('/stores/priority', { params: { month } }),
|
||
})
|
||
|
||
const { data: cardData, isLoading: cardLoading } = useQuery({
|
||
queryKey: ['store', storeCode, 'daily-card', month],
|
||
queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`, { params: { month } }),
|
||
})
|
||
|
||
const { data: tasksData, isLoading: tasksLoading } = useQuery({
|
||
queryKey: ['store', storeCode, 'tasks', month],
|
||
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }),
|
||
})
|
||
|
||
const { data: dailyData, isLoading: dailyLoading } = useQuery({
|
||
queryKey: ['store', storeCode, 'daily', month],
|
||
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }),
|
||
})
|
||
|
||
const { data: followupData, isLoading: followupLoading } = useQuery({
|
||
queryKey: ['store', storeCode, 'followup', month],
|
||
queryFn: () => api.get('/tasks/followup', { params: { month } }),
|
||
})
|
||
|
||
const { data: skuData, isLoading: skuLoading } = useQuery({
|
||
queryKey: ['sku-attach', storeCode],
|
||
queryFn: () => api.get('/sku/attach', { params: { month } }),
|
||
})
|
||
|
||
const { data: abcData, isLoading: abcLoading } = useQuery({
|
||
queryKey: ['sku-abc'],
|
||
queryFn: () => api.get('/sku/abc', { params: { month } }),
|
||
})
|
||
|
||
// StoreDetailPage queries
|
||
const { data: storeDetailData, isLoading: storeDetailLoading } = useQuery({
|
||
queryKey: ['store-detail', storeCode, month],
|
||
queryFn: () => api.get(`/stores/${storeCode}`, { params: { month } }),
|
||
})
|
||
|
||
const { data: healthData, isLoading: healthLoading } = 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 || []
|
||
const dailyRows = (dailyData as any)?.data || []
|
||
const followupRows = (followupData as any)?.data || []
|
||
const followup = followupRows.find((f: any) => f.store_code === storeCode)
|
||
const skuRows = (skuData as any)?.data || []
|
||
const abcRows = (abcData as any)?.data || []
|
||
const coreSKUs = abcRows.filter((r: any) => r.abc_class === 'A').slice(0, 10)
|
||
|
||
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 || '-'
|
||
|
||
const todoTasks = tasks.filter((t: any) => t.status === '待启动' || t.status === '进行中')
|
||
const doneTasks = tasks.filter((t: any) => t.status === '已验收' || t.status === '已回滚')
|
||
const passCount = tasks.filter((t: any) => t.verification_result === '达标').length
|
||
const improvingCount = tasks.filter((t: any) => t.verification_result === '改善中').length
|
||
|
||
const allAnomalyItems: { module: string; metric: string; value: number; baseline: number; is_anomaly: boolean }[] = []
|
||
anomalies.forEach((a: any) => {
|
||
const list = typeof a.anomalies === 'string' ? JSON.parse(a.anomalies) : a.anomalies || []
|
||
list.forEach((item: any) => {
|
||
allAnomalyItems.push({ module: a.module, metric: item.metric, value: item.value, baseline: item.baseline, is_anomaly: item.is_anomaly })
|
||
})
|
||
})
|
||
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
||
|
||
const executeMutation = useMutation({
|
||
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
||
setExecuteText('')
|
||
setActiveTaskId(null)
|
||
},
|
||
})
|
||
|
||
const riskColor = (level: string) => {
|
||
if (level === '红色') return 'bg-red-100 text-red-700 border-red-300'
|
||
if (level === '黄色') return 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||
return 'bg-green-100 text-green-700 border-green-300'
|
||
}
|
||
|
||
const pageLoading = storesLoading || priorityLoading || cardLoading || tasksLoading || dailyLoading || followupLoading || skuLoading || abcLoading || storeDetailLoading || healthLoading
|
||
|
||
if (pageLoading) {
|
||
return <LoadingSpinner text="加载店长工作台..." />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xl font-bold">店长工作台</h1>
|
||
<div className="flex items-center gap-3">
|
||
<MonthPicker month={month} onChange={setMonth} />
|
||
<SearchSelect
|
||
value={storeCode}
|
||
onChange={setStoreCode}
|
||
options={stores.map((s: any) => ({ value: s.code, label: s.name }))}
|
||
emptyLabel="选择门店"
|
||
className="min-w-[160px]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 门店概况 */}
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<p className="text-xs text-muted-foreground">门店名称</p>
|
||
<p className="mt-1 text-lg font-bold">{storeName}</p>
|
||
</div>
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<p className="text-xs text-muted-foreground">风险等级</p>
|
||
<div className="mt-1">
|
||
{storeInfo?.risk && <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-sm font-medium ${riskColor(storeInfo.risk)}`}>{storeInfo.risk}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<p className="text-xs text-muted-foreground">主要问题</p>
|
||
<p className="mt-1 text-sm font-medium">{primaryIssue}</p>
|
||
</div>
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<p className="text-xs text-muted-foreground">任务完成率</p>
|
||
<p className="mt-1 text-lg font-bold">{tasks.length > 0 ? Math.round(doneTasks.length / tasks.length * 100) : 0}%</p>
|
||
<p className="text-xs text-muted-foreground">{doneTasks.length}/{tasks.length} 已完成</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* KPI达成率 */}
|
||
<KPISection month={month} level="store" storeCode={storeCode} />
|
||
|
||
{/* 最新经营概览 */}
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-sm font-bold">最新经营日概览</h2>
|
||
<p className="text-xs text-muted-foreground">
|
||
{card?.target_date ? `${String(card.target_date).substring(0, 10)} 当日数据 vs ${String(card.baseline_date).substring(0, 10)} 一周前同日对比` : `${month} 月末单日 vs 一周前同日对比`}
|
||
</p>
|
||
</div>
|
||
{anomalyCount > 0 && (
|
||
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} 项异常</span>
|
||
)}
|
||
</div>
|
||
{allAnomalyItems.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">暂无数据</p>
|
||
) : (
|
||
<div className="grid gap-2 grid-cols-5">
|
||
{allAnomalyItems.map((item, idx) => {
|
||
const isCurrency = item.metric === '实收' || item.metric === '营业收入' || item.metric === '优惠'
|
||
return (
|
||
<div key={idx} className={`rounded-md border p-3 ${item.is_anomaly ? 'border-red-200 bg-red-50/50' : ''}`}>
|
||
<p className="text-xs text-muted-foreground">{item.metric}</p>
|
||
<p className={`mt-1 text-lg font-bold ${item.is_anomaly ? 'text-red-600' : ''}`}>
|
||
{isCurrency ? formatCurrency(item.value) : formatNumber(item.value)}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">日基准: {isCurrency ? formatCurrency(item.baseline) : formatNumber(item.baseline)}{item.is_anomaly && <span className="text-red-500"> ⚠</span>}</p>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 本周指标进度 */}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
|
||
{/* 本周指标进度条 */}
|
||
{followup && (
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">本周指标进度</h2>
|
||
<div className="space-y-3">
|
||
{[
|
||
{ label: '实收', baseline: Number(followup.baseline_received), target: Number(followup.target_received), actual: Number(followup.actual_received), lower: false, isCurrency: true },
|
||
{ label: '客单价', baseline: Number(followup.baseline_avg_bill), target: Number(followup.target_avg_bill), actual: Number(followup.actual_avg_bill), lower: false, isCurrency: true },
|
||
{ label: '优惠率', baseline: Number(followup.baseline_discount_rate), target: Number(followup.target_discount_rate), actual: Number(followup.actual_discount_rate), lower: true, isCurrency: false },
|
||
{ label: '毛利率', baseline: Number(followup.baseline_margin_rate), target: Number(followup.target_margin_rate), actual: Number(followup.actual_margin_rate), lower: false, isCurrency: false },
|
||
].map((m) => {
|
||
const pct = !isNaN(m.target) && m.target !== 0 && !isNaN(m.actual)
|
||
? Math.min(Math.round((m.actual / m.target) * 100), 200) : 0
|
||
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
|
||
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : `${formatNumber(v)}%`
|
||
return (
|
||
<div key={m.label}>
|
||
<div className="mb-1 flex items-center justify-between text-xs">
|
||
<span className="font-medium">{m.label}</span>
|
||
<span className="text-muted-foreground">
|
||
目标: {fmt(m.target)} | 实际: {fmt(m.actual)} | {pct}% {achieved ? '✓' : ''}
|
||
</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className={`h-full rounded-full transition-all ${achieved ? 'bg-green-500' : pct >= 80 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 日度趋势 + 月度指标对比 */}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
{/* 日度实收趋势 */}
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">日度实收趋势</h2>
|
||
{dailyRows.length > 0 ? (
|
||
<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 tick={{ fontSize: 10 }} />
|
||
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} />
|
||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||
</div>
|
||
|
||
{/* 月度指标对比 */}
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">月度指标对比</h2>
|
||
{followup ? (
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground">
|
||
<th className="py-2 text-left font-medium">指标</th>
|
||
<th className="py-2 text-right font-medium">月基线</th>
|
||
<th className="py-2 text-right font-medium">目标</th>
|
||
<th className="py-2 text-right font-medium">实际</th>
|
||
<th className="py-2 text-right font-medium">达成率</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
{ label: '实收', baseline: Number(followup.baseline_received), target: Number(followup.target_received), actual: Number(followup.actual_received), lower: false, isCurrency: true },
|
||
{ label: '客单价', baseline: Number(followup.baseline_avg_bill), target: Number(followup.target_avg_bill), actual: Number(followup.actual_avg_bill), lower: false, isCurrency: true },
|
||
{ label: '优惠率(%)', baseline: Number(followup.baseline_discount_rate), target: Number(followup.target_discount_rate), actual: Number(followup.actual_discount_rate), lower: true, isCurrency: false },
|
||
{ label: '理论毛利率(%)', baseline: Number(followup.baseline_margin_rate), target: Number(followup.target_margin_rate), actual: Number(followup.actual_margin_rate), lower: false, isCurrency: false },
|
||
{ label: '异常率(%)', baseline: Number(followup.baseline_anomaly_rate), target: Number(followup.target_anomaly_rate), actual: Number(followup.actual_anomaly_rate), lower: true, isCurrency: false },
|
||
{ label: '会员占比(%)', baseline: Number(followup.baseline_member_share), target: Number(followup.target_member_share), actual: Number(followup.actual_member_share), lower: false, isCurrency: false },
|
||
].map((m) => {
|
||
const achieved = !isNaN(m.actual) && !isNaN(m.target) && (m.lower ? m.actual <= m.target : m.actual >= m.target)
|
||
const rate = !isNaN(m.actual) && !isNaN(m.baseline) && m.baseline !== 0 ? Math.round((m.actual / m.baseline) * 100) : null
|
||
const fmt = (v: number) => isNaN(v) ? '-' : m.isCurrency ? formatCurrency(v) : formatNumber(v)
|
||
return (
|
||
<tr key={m.label} className="border-b last:border-0">
|
||
<td className="py-2 text-left">{m.label}</td>
|
||
<td className="py-2 text-right text-muted-foreground">{fmt(m.baseline)}</td>
|
||
<td className="py-2 text-right text-blue-600">{fmt(m.target)}</td>
|
||
<td className={`py-2 text-right font-medium ${achieved ? 'text-green-600' : 'text-red-600'}`}>{fmt(m.actual)}</td>
|
||
<td className={`py-2 text-right ${achieved ? 'text-green-600' : 'text-red-600'}`}>
|
||
{rate !== null ? `${rate}%` : '-'}
|
||
{achieved ? ' ✓' : ' ✗'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
) : <p className="py-16 text-center text-sm text-muted-foreground">暂无数据</p>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 工作重点和行动计划 */}
|
||
{followup && (followup.work_focus || followup.action_plan) && (
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
{followup.work_focus && (
|
||
<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">{followup.work_focus}</p>
|
||
</div>
|
||
)}
|
||
{followup.action_plan && (
|
||
<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">{followup.action_plan}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 核心SKU备货提醒 */}
|
||
{coreSKUs.length > 0 && (
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">核心SKU备货提醒 (A级)</h2>
|
||
<div className="space-y-2">
|
||
{coreSKUs.map((s: any, idx: number) => (
|
||
<div key={idx} className="flex items-center justify-between rounded-md border p-2">
|
||
<div className="flex items-center gap-2">
|
||
<Badge type="priority" text="A" />
|
||
<span className="text-sm font-medium">{s.dish_name || s.sku_name || '-'}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||
<span>营收占比: {Number(s.revenue_share_pct || 0).toFixed(1)}%</span>
|
||
<span>累计: {Number(s.cumulative_revenue_share_pct || 0).toFixed(1)}%</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 待办任务 */}
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">待办任务 ({todoTasks.length})</h2>
|
||
<div className="space-y-2">
|
||
{todoTasks.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">暂无待办任务</p>
|
||
) : todoTasks.map((t: any) => (
|
||
<div key={t.task_id} className="rounded-md border p-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<Badge type="priority" text={t.priority} />
|
||
<span className="text-sm font-medium">{t.problem_indicator}</span>
|
||
</div>
|
||
<Badge type="status" text={t.status} />
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">{t.action_required}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">截止日: {t.deadline?.substring(0, 10)}</p>
|
||
|
||
{activeTaskId === t.task_id ? (
|
||
<div className="mt-2">
|
||
<textarea
|
||
value={executeText}
|
||
onChange={(e) => setExecuteText(e.target.value)}
|
||
placeholder="填写执行结果和过程证据..."
|
||
className="mb-2 w-full rounded-md border p-2 text-xs"
|
||
rows={2}
|
||
/>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
|
||
disabled={!executeText || executeMutation.isPending}
|
||
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
||
>
|
||
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
||
</button>
|
||
<button
|
||
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
|
||
className="rounded-md border px-3 py-1 text-xs"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
t.status === '待启动' && (
|
||
<button
|
||
onClick={() => setActiveTaskId(t.task_id)}
|
||
className="mt-2 rounded-md border px-3 py-1 text-xs hover:bg-muted"
|
||
>
|
||
填写执行反馈
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 已完成任务 */}
|
||
{doneTasks.length > 0 && (
|
||
<div className="rounded-lg border bg-card p-4">
|
||
<h2 className="mb-3 text-sm font-bold">已完成任务 ({doneTasks.length})</h2>
|
||
<div className="space-y-2">
|
||
{doneTasks.map((t: any) => (
|
||
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
|
||
<div className="flex items-center gap-2">
|
||
<Badge type="priority" text={t.priority} />
|
||
<span className="text-sm">{t.problem_indicator}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||
<Badge type="status" text={t.status} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ========== 门店详情区 ========== */}
|
||
{sc && Number(sc.received) > 0 && (
|
||
<>
|
||
{/* 核心指标 - 月度汇总 */}
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
|
||
<MetricCard title="营业收入" value={sc.consumption} format="currency" />
|
||
<MetricCard title="优惠" value={sc.discount} format="currency" />
|
||
<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" name="得分" 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 formatter={(v: any) => [v, '得分']} />
|
||
</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={60} dataKey="value" nameKey="name" labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name} ${value}%`}</tspan>}>
|
||
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
|
||
</Pie>
|
||
<Tooltip formatter={(v: any) => [`${v}%`, '占比']} />
|
||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||
</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 formatter={(v: any) => [`${v}%`, '占比']} />
|
||
<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>
|
||
)
|
||
}
|