Files
SBrainCO/client/src/pages/StorePage.tsx
T
freedakgmail 271ad8041d feat: 全页面增加营业收入/优惠指标 + 修复趋势预测空数据 + 区域对比口径统一 + StorePage最新经营日概览
- 后端: 所有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显示
2026-08-02 14:56:00 +08:00

879 lines
49 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 { 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>
)
}