401 lines
21 KiB
TypeScript
401 lines
21 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts'
|
||
import api from '@/lib/api'
|
||
import { MetricCard } from '@/components/MetricCard'
|
||
import { Badge } from '@/components/Badge'
|
||
import { DataTable } from '@/components/DataTable'
|
||
import { Pagination } from '@/components/Pagination'
|
||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||
import { useState, useMemo } from 'react'
|
||
|
||
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||
const PAGE_SIZE = 10
|
||
|
||
export function DashboardPage() {
|
||
const navigate = useNavigate()
|
||
const [priorityPage, setPriorityPage] = useState(1)
|
||
|
||
const { data: overview, isLoading: odLoading } = useQuery({
|
||
queryKey: ['overview'],
|
||
queryFn: () => api.get('/overview'),
|
||
})
|
||
|
||
const { data: daily, isLoading: dailyLoading } = useQuery({
|
||
queryKey: ['overview/daily'],
|
||
queryFn: () => api.get('/overview/daily'),
|
||
})
|
||
|
||
const { data: riskData, isLoading: riskLoading } = useQuery({
|
||
queryKey: ['stores/risk'],
|
||
queryFn: () => api.get('/stores/risk'),
|
||
})
|
||
|
||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||
queryKey: ['stores/priority'],
|
||
queryFn: () => api.get('/stores/priority'),
|
||
})
|
||
|
||
const { data: loopHealth, isLoading: loopLoading } = useQuery({
|
||
queryKey: ['loop-health'],
|
||
queryFn: () => api.get('/tasks/loop-health'),
|
||
})
|
||
|
||
const { data: quadrantData } = useQuery({
|
||
queryKey: ['stores/quadrant'],
|
||
queryFn: () => api.get('/stores/quadrant'),
|
||
})
|
||
|
||
const { data: platformData } = useQuery({
|
||
queryKey: ['platform/economics'],
|
||
queryFn: () => api.get('/platform/economics'),
|
||
})
|
||
|
||
const { data: alertsData } = useQuery({
|
||
queryKey: ['sa-alerts'],
|
||
queryFn: () => api.get('/situational-awareness/alerts'),
|
||
})
|
||
|
||
const { data: expenseData } = useQuery({
|
||
queryKey: ['store-expense/overview'],
|
||
queryFn: () => api.get('/store-expense/overview'),
|
||
})
|
||
|
||
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
|
||
|
||
const od = (overview as any)?.data
|
||
const ex = (expenseData as any)?.data
|
||
const riskRows = (riskData as any)?.data || []
|
||
const priorityRows = (priorityData as any)?.data || []
|
||
const dailyRows = (daily as any)?.data || []
|
||
const lh = (loopHealth as any)?.data
|
||
const quadrantRows = (quadrantData as any)?.data || []
|
||
const platformRows = (platformData as any)?.data || []
|
||
const alerts = ((alertsData as any)?.data?.alerts || []) as any[]
|
||
const alertSummary = (alertsData as any)?.data
|
||
|
||
const QUADRANT_COLORS: Record<string, string> = {
|
||
'明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7',
|
||
}
|
||
|
||
const quadrantScatterData = quadrantRows.map((r: any) => ({
|
||
name: r.store_name,
|
||
x: Number(r.avg_daily_received || 0),
|
||
y: Number(r.theoretical_margin_pct || 0),
|
||
quadrant: r.management_quadrant,
|
||
fill: QUADRANT_COLORS[r.management_quadrant] || '#999',
|
||
}))
|
||
|
||
const platformCostData = platformRows.slice(0, 15).map((r: any) => ({
|
||
name: r.store_name?.substring(0, 6) || '',
|
||
meituan: Number(r.meituan_cost_rate_pct || 0),
|
||
eleme: Number(r.eleme_cost_rate_pct || 0),
|
||
douyin: Number(r.douyin_cost_rate_pct || 0),
|
||
}))
|
||
|
||
const p0Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0'))
|
||
const p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P1'))
|
||
const p0Received = p0Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0)
|
||
const p1Received = p1Stores.reduce((s: number, r: any) => s + Number(r.received || 0), 0)
|
||
const otherReceived = od?.received ? Number(od.received) - p0Received - p1Received : 0
|
||
const waterfallData = [
|
||
{ name: 'P0门店', value: p0Received, fill: '#ef4444' },
|
||
{ name: 'P1门店', value: p1Received, fill: '#eab308' },
|
||
{ name: '其他门店', value: otherReceived, fill: '#22c55e' },
|
||
{ name: '合计', value: Number(od?.received || 0), fill: '#3b82f6' },
|
||
]
|
||
|
||
const riskSummary = riskRows.reduce((acc: any, r: any) => {
|
||
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
|
||
return acc
|
||
}, {})
|
||
|
||
const totalStores = riskRows.length
|
||
const riskPieData = Object.entries(riskSummary).map(([name, value]) => ({
|
||
name,
|
||
value: value as number,
|
||
pct: totalStores > 0 ? Math.round((value as number / totalStores) * 100) : 0,
|
||
}))
|
||
|
||
const prioritySummary = priorityRows.reduce((acc: any, r: any) => {
|
||
const key = r.action_priority
|
||
acc[key] = (acc[key] || 0) + 1
|
||
return acc
|
||
}, {})
|
||
|
||
const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1'))
|
||
const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage])
|
||
|
||
// 闭环健康度综合得分
|
||
const loopScore = lh ? (
|
||
(parseFloat(lh.task_generation_rate) +
|
||
parseFloat(lh.store_execution_rate) +
|
||
parseFloat(lh.weekly_check_rate) +
|
||
parseFloat(lh.monthly_review_rate) +
|
||
parseFloat(lh.practice_promotion_rate)) / 5
|
||
) : 0
|
||
|
||
// 计算环比趋势:最后7天 vs 前7天
|
||
const last7 = dailyRows.slice(-7)
|
||
const prev7 = dailyRows.slice(-14, -7)
|
||
const sumKey = (arr: any[], key: string) => arr.reduce((s, r) => s + Number(r[key] || 0), 0)
|
||
const trend = (curr: number, prev: number) => prev > 0 ? Math.round((curr - prev) / prev * 1000) / 10 : 0
|
||
|
||
const trendReceived = trend(sumKey(last7, 'received'), sumKey(prev7, 'received'))
|
||
const trendBills = trend(sumKey(last7, 'bill_count'), sumKey(prev7, 'bill_count'))
|
||
const trendAvgBill = trend(
|
||
last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0,
|
||
prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0,
|
||
)
|
||
|
||
if (pageLoading) {
|
||
return <LoadingSpinner text="加载驾驶舱数据..." />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold">总部驾驶舱</h1>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">全部门店经营总览 · 2026年4月</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} 家门店</span>
|
||
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700">红色 {riskSummary['红色'] || 0}</span>
|
||
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700">黄色 {riskSummary['黄色'] || 0}</span>
|
||
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿色 {riskSummary['绿色'] || 0}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 经营指标 */}
|
||
<CollapsibleSection title="经营指标" subtitle="实收、账单数、客单价等核心经营数据">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="实收" value={od?.received} format="currency" trend={trendReceived} description="门店实际收到的金额,扣除优惠后的净收入" />
|
||
<MetricCard title="账单数" value={od?.bill_count} format="number" trend={trendBills} description="全部门店累计的账单(订单)总数" />
|
||
<MetricCard title="客单价" value={od?.avg_bill_value} format="currency" trend={trendAvgBill} description="平均每笔账单的实收金额 = 实收 ÷ 账单数" />
|
||
<MetricCard title="P0+P1门店" value={p0p1Stores.length} format="number" description="需要重点整改的门店数:P0为紧急修复,P1为重点整改" />
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* 运营指标 */}
|
||
<CollapsibleSection title="运营指标" subtitle="优惠率、毛利率、会员占比等运营比率">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||
<MetricCard title="优惠率" value={od?.discount_rate_pct} format="percent" description="优惠总额占消费总额的比例,越高让利越大" />
|
||
<MetricCard title="理论毛利率" value={od?.theoretical_margin_pct} format="percent" description="理论利润占实收的比例(按标准成本计算),反映定价与成本的关系" />
|
||
<MetricCard title="会员占比" value={od?.member_share_pct} format="percent" description="会员消费金额占总实收的比例,反映会员运营效果" />
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* 利润与成本 */}
|
||
{ex && (
|
||
<CollapsibleSection title="利润与成本" subtitle="食材成本、经营费用、净利润等财务指标">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="理论净利润" value={ex.theoretical_net_profit} format="currency" description={`净利率 ${formatPercent(ex.theoretical_net_margin_pct)} · 按标准成本计算的理论利润`} />
|
||
<MetricCard title="实际净利润" value={ex.actual_net_profit} format="currency" description={`净利率 ${formatPercent(ex.actual_net_margin_pct)} · 盈利 ${ex.profitable_stores}家 / 亏损 ${ex.loss_stores}家`} />
|
||
<MetricCard title="食材成本" value={ex.total_food_cost} format="currency" description={`成本率 ${formatPercent(ex.actual_food_cost_rate_pct)} · 理论成本率 ${formatPercent(ex.theoretical_food_cost_rate_pct)}`} />
|
||
<MetricCard title="经营费用" value={ex.total_expense} format="currency" description={`费用率 ${formatPercent(ex.overall_expense_rate_pct)} · 人工 ${formatCurrency(ex.total_wage)} · 房租 ${formatCurrency(ex.total_rent)}`} />
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="人工费用" value={ex.total_wage} format="currency" description={`占实收 ${formatPercent(ex.overall_wage_rate_pct)}`} />
|
||
<MetricCard title="房租费用" value={ex.total_rent} format="currency" description={`占实收 ${formatPercent(ex.overall_rent_rate_pct)}`} />
|
||
<MetricCard title="水电费用" value={ex.total_utility} format="currency" description={`占实收 ${formatPercent(ex.overall_utility_rate_pct)}`} />
|
||
<MetricCard title="外卖佣金" value={ex.total_commission} format="currency" description="外卖平台佣金合计" />
|
||
</div>
|
||
</CollapsibleSection>
|
||
)}
|
||
|
||
{/* 闭环健康度 */}
|
||
{lh && (
|
||
<CollapsibleSection
|
||
title="闭环健康度"
|
||
subtitle="任务生成→店长执行→区域检查→月度验收→经验推广"
|
||
headerRight={
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">综合得分</span>
|
||
<span className={`text-lg font-bold ${loopScore >= 80 ? 'text-green-600' : loopScore >= 60 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||
{loopScore.toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||
{[
|
||
{ label: '任务生成率', value: lh.task_generation_rate, desc: '系统自动生成的整改任务占应生成任务的比例' },
|
||
{ label: '店长执行率', value: lh.store_execution_rate, desc: '店长已提交反馈的任务占分配任务的比例' },
|
||
{ label: '区域周检率', value: lh.weekly_check_rate, desc: '区域经理完成周度检查的比例' },
|
||
{ label: '月度验收率', value: lh.monthly_review_rate, desc: '月度完成验收的任务比例' },
|
||
{ label: '经验推广率', value: lh.practice_promotion_rate, desc: '达标经验被推广到其他门店的比例' },
|
||
].map((item) => {
|
||
const num = parseFloat(item.value)
|
||
const isLow = num < 60
|
||
const isMid = num >= 60 && num < 80
|
||
return (
|
||
<div key={item.label} className={`rounded-md border p-3 ${isLow ? 'border-red-200 bg-red-50/50' : isMid ? 'border-yellow-200 bg-yellow-50/50' : 'border-green-200 bg-green-50/50'}`} title={item.desc}>
|
||
<div className="flex items-center gap-1">
|
||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||
<span className="flex h-3.5 w-3.5 cursor-help items-center justify-center rounded-full bg-muted text-[9px] text-muted-foreground" title={item.desc}>?</span>
|
||
</div>
|
||
<p className={`mt-1 text-xl font-bold ${isLow ? 'text-red-600' : isMid ? 'text-yellow-600' : 'text-green-600'}`}>{num.toFixed(1)}%</p>
|
||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className={`h-full rounded-full ${isLow ? 'bg-red-500' : isMid ? 'bg-yellow-500' : 'bg-green-500'}`}
|
||
style={{ width: `${Math.min(num, 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</CollapsibleSection>
|
||
)}
|
||
|
||
{/* 态势感知预警 */}
|
||
{alerts.length > 0 && (
|
||
<CollapsibleSection
|
||
title="态势感知预警"
|
||
subtitle="营收异动 · 成本异动 · 人力异动 · 平台依赖 · 任务逾期"
|
||
headerRight={
|
||
<div className="flex gap-2">
|
||
{alertSummary?.red > 0 && <span className="rounded-md bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">红色 {alertSummary.red}</span>}
|
||
{alertSummary?.orange > 0 && <span className="rounded-md bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-700">橙色 {alertSummary.orange}</span>}
|
||
<span className="text-xs text-muted-foreground">共 {alertSummary?.total || 0} 条</span>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
{alerts.slice(0, 6).map((a, i) => (
|
||
<div key={i} className={`flex items-start gap-2 rounded-md border p-2.5 ${a.level === 'red' ? 'border-red-200 bg-red-50/50' : a.level === 'orange' ? 'border-orange-200 bg-orange-50/50' : 'border-yellow-200 bg-yellow-50/50'}`}>
|
||
<span className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${a.level === 'red' ? 'text-red-700' : a.level === 'orange' ? 'text-orange-700' : 'text-yellow-700'}`}>
|
||
{a.level === 'red' ? '红' : a.level === 'orange' ? '橙' : '黄'}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-xs font-medium">{a.title}</p>
|
||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{a.detail}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CollapsibleSection>
|
||
)}
|
||
|
||
{/* 图表区 */}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<CollapsibleSection title="日度实收趋势" subtitle="近30天实收变化">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<LineChart data={dailyRows}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||
<Tooltip
|
||
labelFormatter={(v) => v?.substring(0, 10)}
|
||
formatter={(v: any) => formatCurrency(v)}
|
||
/>
|
||
<Line type="monotone" dataKey="received" stroke="#3b82f6" name="实收" dot={false} strokeWidth={2} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
<CollapsibleSection title="门店风险分级分布" subtitle="按红/黄/绿分级统计">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<PieChart>
|
||
<Pie
|
||
data={riskPieData}
|
||
cx="50%" cy="50%" outerRadius={80}
|
||
dataKey="value"
|
||
label={({ name, value, pct }: any) => `${name} ${value}家 (${pct}%)`}
|
||
>
|
||
{riskPieData.map((entry) => (
|
||
<Cell key={entry.name} fill={RISK_COLORS[entry.name as keyof typeof RISK_COLORS] || '#gray'} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip />
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
</div>
|
||
|
||
{/* 第二行图表 */}
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
{/* 象限散点图 */}
|
||
<CollapsibleSection title="门店经营象限" subtitle="日均实收 × 毛利率">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<ScatterChart>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis type="number" dataKey="x" name="日均实收" tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 10 }} />
|
||
<YAxis type="number" dataKey="y" name="毛利率" unit="%" tick={{ fontSize: 10 }} />
|
||
<ZAxis range={[40, 40]} />
|
||
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, n: string) => n === '毛利率' ? `${Number(v).toFixed(1)}%` : n === '日均实收' ? `¥${Number(v).toFixed(0)}` : v} />
|
||
{Object.entries(QUADRANT_COLORS).map(([name, color]) => (
|
||
<Scatter key={name} name={name} data={quadrantScatterData.filter((d: any) => d.quadrant === name)} fill={color} />
|
||
))}
|
||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||
</ScatterChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
{/* P0/P1实收覆盖瀑布图 */}
|
||
<CollapsibleSection title="P0/P1实收覆盖" subtitle="按优先级拆分实收">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<BarChart data={waterfallData}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||
{waterfallData.map((entry, idx) => <Cell key={idx} fill={entry.fill} />)}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
{/* 平台成本率柱状图 */}
|
||
<CollapsibleSection title="平台成本率TOP15" subtitle="三平台成本率对比">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<BarChart data={platformCostData}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-30} textAnchor="middle" height={50} />
|
||
<YAxis unit="%" tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||
<Bar dataKey="meituan" name="美团" fill="#f97316" />
|
||
<Bar dataKey="eleme" name="饿了么" fill="#3b82f6" />
|
||
<Bar dataKey="douyin" name="抖音" fill="#ec4899" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
</div>
|
||
|
||
{/* P0/P1 门店列表 */}
|
||
<CollapsibleSection
|
||
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
|
||
subtitle="需紧急修复和重点整改的门店清单"
|
||
headerRight={
|
||
<div className="flex gap-2">
|
||
{Object.entries(prioritySummary).filter(([k]) => k?.startsWith('P0') || k?.startsWith('P1')).map(([k, v]) => (
|
||
<span key={k} className="text-xs text-muted-foreground">{k}: {v as number}家</span>
|
||
))}
|
||
</div>
|
||
}
|
||
>
|
||
<Pagination page={priorityPage} pageSize={PAGE_SIZE} total={p0p1Stores.length} onPageChange={setPriorityPage} />
|
||
<div className="mt-3">
|
||
<DataTable
|
||
columns={[
|
||
{ key: 'store_name', label: '门店' },
|
||
{ key: 'action_priority', label: '优先级', render: (r) => <Badge type="priority" text={r.action_priority} /> },
|
||
{ key: 'problem_count', label: '问题数', align: 'center' },
|
||
{ key: 'problem_combination', label: '问题组合' },
|
||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||
]}
|
||
data={pagedP0P1}
|
||
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
|
||
/>
|
||
</div>
|
||
</CollapsibleSection>
|
||
</div>
|
||
)
|
||
}
|