Files
SBrainCO/client/src/pages/DashboardPage.tsx
T

401 lines
21 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 } 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"> · 20264</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>
)
}