feat: 新增老板驾驶舱页面 - 利润瀑布/风险态势/成本异常/营收排名/日度趋势

This commit is contained in:
freedakgmail
2026-07-30 14:24:17 +08:00
parent 6c5d8ed5d3
commit 22d711fb73
4 changed files with 373 additions and 1 deletions
+2
View File
@@ -23,6 +23,7 @@ import { OntologyPage } from '@/pages/OntologyPage'
import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
import { BossPage } from '@/pages/BossPage'
import { LoginPage } from '@/pages/LoginPage'
const queryClient = new QueryClient({
@@ -67,6 +68,7 @@ export default function App() {
<Layout user={user} onLogout={handleLogout}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/boss" element={<BossPage />} />
<Route path="/regional" element={<RegionalPage />} />
<Route path="/store" element={<StorePage />} />
<Route path="/tasks" element={<TasksPage />} />
+2 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LayoutProps {
@@ -25,6 +25,7 @@ const menuGroups: MenuGroup[] = [
{
title: '经营管理',
items: [
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
+329
View File
@@ -0,0 +1,329 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, LineChart, Line, PieChart, Pie, Legend, ComposedChart } from 'recharts'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt } from 'lucide-react'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
export function BossPage() {
const navigate = useNavigate()
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: expenseData, isLoading: exLoading } = useQuery({
queryKey: ['store-expense/overview'],
queryFn: () => api.get('/store-expense/overview'),
})
const { data: waterfallData, isLoading: wfLoading } = useQuery({
queryKey: ['overview/profit-waterfall'],
queryFn: () => api.get('/overview/profit-waterfall'),
})
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: costOverview, isLoading: costLoading } = useQuery({
queryKey: ['cost-analysis/store-overview'],
queryFn: () => api.get('/cost-analysis/store-overview'),
})
const { data: expenseStructure, isLoading: structLoading } = useQuery({
queryKey: ['store-expense/expense-structure'],
queryFn: () => api.get('/store-expense/expense-structure'),
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
const od = (overview as any)?.data
const ex = (expenseData as any)?.data
const wf = (waterfallData as any)?.data
const riskRows = (riskData as any)?.data || []
const priorityRows = (priorityData as any)?.data || []
const dailyRows = (daily as any)?.data || []
const costOv = (costOverview as any)?.data || {}
const structRows = (expenseStructure as any)?.data || []
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
return acc
}, {})
const riskRevSummary = riskRows.reduce((acc: any, r: any) => {
if (!acc[r.risk_level]) acc[r.risk_level] = 0
acc[r.risk_level] += Number(r.received || 0)
return acc
}, {})
const totalStores = riskRows.length
const totalRev = Number(od?.received || 0)
// P0/P1门店
const p0Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0'))
const p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P1'))
const p0p1Stores = [...p0Stores, ...p1Stores]
// 营收TOP5 / BOTTOM5
const sortedByRev = [...riskRows].sort((a: any, b: any) => Number(b.received || 0) - Number(a.received || 0))
const top5 = sortedByRev.slice(0, 5)
const bottom5 = sortedByRev.filter((r: any) => Number(r.received) > 0).slice(-5).reverse()
const rankingData = [...top5, ...bottom5].map((r: any, i: number) => ({
name: r.store_name?.substring(0, 6) || '',
value: Number(r.received || 0),
code: r.store_code,
isTop: i < 5,
}))
// 利润瀑布数据
const waterfallSteps = wf ? [
{ name: '实收', value: Number(wf.received), type: 'total' },
{ name: '食材成本', value: -Number(wf.food_cost), type: 'cost' },
{ name: '人工', value: -Number(wf.wage), type: 'cost' },
{ name: '房租', value: -Number(wf.rent), type: 'cost' },
{ name: '水电', value: -Number(wf.utility), type: 'cost' },
{ name: '宿舍', value: -Number(wf.dorm), type: 'cost' },
{ name: '外卖佣金', value: -Number(wf.commission), type: 'cost' },
{ name: '其他费用', value: -Number(wf.other_expense), type: 'cost' },
{ name: '净利润', value: Number(wf.net_profit), type: 'profit' },
] : []
// 瀑布图累计计算
let cumulative = 0
const waterfallChart = waterfallSteps.map((step) => {
if (step.type === 'total' || step.type === 'profit') {
cumulative = step.value
return { name: step.name, base: 0, value: step.value, fill: step.type === 'profit' ? '#22c55e' : '#3b82f6' }
} else {
const base = cumulative + step.value
const result = { name: step.name, base, value: -step.value, fill: '#ef4444' }
cumulative = base
return result
}
})
// 费用结构饼图
const pieData = structRows.map((r: any) => ({ name: r.account_name?.length > 6 ? r.account_name.substring(0, 6) + '…' : r.account_name, value: parseFloat(r.amount) }))
const PIE_COLORS = ['#3b82f6', '#22c55e', '#f97316', '#eab308', '#a855f7', '#ec4899', '#06b6d4', '#64748b']
// 日度趋势
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'))
// 成本超耗TOP5
const costRed = Number(costOv.red_count || 0)
const costOrange = Number(costOv.orange_count || 0)
const costGreen = Number(costOv.green_count || 0)
if (pageLoading) {
return <LoadingSpinner text="加载老板驾驶舱..." />
}
return (
<div className="space-y-4">
{/* 标题 */}
<div className="flex items-center gap-2">
<Crown className="text-yellow-500" size={24} />
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · 20264</p>
</div>
</div>
{/* ① 核心经营指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="实收总额" value={od?.received} format="currency" trend={trendReceived} description={`公式:全部门店实收合计。环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} />
<MetricCard title="实际净利润" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`净利率 ${formatPercent(ex?.actual_net_margin_pct)} · 公式:实收 - 食材成本 - 经营费用。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改`} />
<MetricCard title="门店数" value={totalStores} unit="家" description={`盈利 ${ex?.profitable_stores}家 / 亏损 ${ex?.loss_stores}家。无费用数据 ${ex?.no_expense_stores}`} />
<MetricCard title="客单价" value={od?.avg_bill_value} format="currency" trend={trend((last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(od?.bill_count)} 笔 · 公式:实收 ÷ 账单数`} />
</div>
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 净利润">
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="middle" height={60} />
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="base" stackId="a" fill="transparent" />
<Bar dataKey="value" stackId="a" radius={[4, 4, 0, 0]}>
{waterfallChart.map((entry, i) => <Cell key={i} fill={entry.fill} />)}
</Bar>
</ComposedChart>
</ResponsiveContainer>
<div className="mt-2 grid grid-cols-3 gap-2 md:grid-cols-5">
{waterfallSteps.filter(s => s.type === 'cost').map(s => (
<div key={s.name} className="rounded border p-2 text-center">
<p className="text-xs text-muted-foreground">{s.name}</p>
<p className="text-sm font-bold text-red-600">{formatCurrency(Math.abs(s.value))}</p>
</div>
))}
<div className="rounded border border-green-200 bg-green-50/40 p-2 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.net_profit)}</p>
</div>
</div>
</CollapsibleSection>
)}
{/* ③ 风险态势 */}
<div className="grid gap-4 lg:grid-cols-3">
<div className={`rounded-lg border p-4 ${riskSummary['红色'] > 15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40'}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="text-red-500" size={20} />
<span className="text-sm font-bold"></span>
</div>
<p className="mt-2 text-3xl font-bold text-red-600">{riskSummary['红色'] || 0}<span className="text-base text-muted-foreground"> </span></p>
<p className="mt-1 text-xs text-muted-foreground"> {formatCurrency(riskRevSummary['红色'])} · {totalRev > 0 ? formatPercent(riskRevSummary['红色'] / totalRev * 100) : '-'}</p>
</div>
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4">
<div className="flex items-center gap-2">
<TrendingDown className="text-yellow-500" size={20} />
<span className="text-sm font-bold"></span>
</div>
<p className="mt-2 text-3xl font-bold text-yellow-600">{riskSummary['黄色'] || 0}<span className="text-base text-muted-foreground"> </span></p>
<p className="mt-1 text-xs text-muted-foreground"> {formatCurrency(riskRevSummary['黄色'])} · {totalRev > 0 ? formatPercent(riskRevSummary['黄色'] / totalRev * 100) : '-'}</p>
</div>
<div className="rounded-lg border border-green-200 bg-green-50/40 p-4">
<div className="flex items-center gap-2">
<TrendingUp className="text-green-500" size={20} />
<span className="text-sm font-bold">绿</span>
</div>
<p className="mt-2 text-3xl font-bold text-green-600">{riskSummary['绿色'] || 0}<span className="text-base text-muted-foreground"> </span></p>
<p className="mt-1 text-xs text-muted-foreground"> {formatCurrency(riskRevSummary['绿色'])} · {totalRev > 0 ? formatPercent(riskRevSummary['绿色'] / totalRev * 100) : '-'}</p>
</div>
</div>
{/* P0/P1 门店清单 */}
{p0p1Stores.length > 0 && (
<CollapsibleSection title={`重点整改门店 (P0 ${p0Stores.length}家 + P1 ${p1Stores.length}家)`} subtitle="需紧急修复和重点整改的门店">
<div className="overflow-auto">
<table className="w-full text-xs">
<thead className="bg-muted">
<tr>
<th className="text-left p-2"></th>
<th className="text-right p-2"></th>
<th className="text-center p-2"></th>
<th className="text-center p-2"></th>
<th className="text-left p-2"></th>
</tr>
</thead>
<tbody>
{p0p1Stores.map((s: any) => (
<tr
key={s.store_code}
className="cursor-pointer border-b hover:bg-muted/50"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<td className="p-2 font-medium">{s.store_name}</td>
<td className="text-right p-2">{formatCurrency(s.received)}</td>
<td className="text-center p-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${s.action_priority?.startsWith('P0') ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'}`}>
{s.action_priority}
</span>
</td>
<td className="text-center p-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold ${s.risk_level === '红色' ? 'bg-red-100 text-red-700' : s.risk_level === '黄色' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
{s.risk_level}
</span>
</td>
<td className="p-2 text-muted-foreground">{s.problem_combination || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</CollapsibleSection>
)}
{/* ④ 成本异常 + 费用结构 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="成本异常分布" subtitle="食材成本超耗情况">
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-red-200 bg-red-50/40 p-3 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold text-red-600">{costRed}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="rounded-lg border border-orange-200 bg-orange-50/40 p-3 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold text-orange-600">{costOrange}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="rounded-lg border border-green-200 bg-green-50/40 p-3 text-center">
<p className="text-xs text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600">{costGreen}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="mt-3 rounded border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xl font-bold text-red-600">{formatCurrency(costOv.total_variance)}</p>
<p className="mt-1 text-xs text-muted-foreground"> - </p>
</div>
</CollapsibleSection>
<CollapsibleSection title="费用结构" subtitle="各费用科目占比">
{!structLoading && pieData.length > 0 && (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${e.name}`}>
{pieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
)}
</CollapsibleSection>
</div>
{/* ⑤ 门店营收排名 */}
<CollapsibleSection title="门店营收排名" subtitle="TOP5 赚钱最多 · BOTTOM5 需关注">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={rankingData} layout="vertical" margin={{ left: 80 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={80} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} onClick={(d: any) => d.code && navigate(`/stores/${d.code}`)}>
{rankingData.map((entry, i) => (
<Cell key={i} fill={entry.isTop ? '#22c55e' : '#ef4444'} className="cursor-pointer" />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
{/* ⑥ 日度实收趋势 */}
<CollapsibleSection title="日度实收趋势" subtitle={`近30天 · 环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`}>
<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 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>
</div>
)
}
+40
View File
@@ -499,4 +499,44 @@ router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) =
} catch (err: any) { sendError(res, err.message) }
})
// 利润瀑布数据
router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
try {
const result = await query(`
WITH full_scope AS (
SELECT
r.received,
COALESCE(e.actual_food_cost, 0) AS actual_food_cost,
COALESCE(e.wage_expense, 0) AS wage_expense,
COALESCE(e.rent_expense, 0) AS rent_expense,
COALESCE(e.utility_expense, 0) AS utility_expense,
COALESCE(e.dorm_expense, 0) AS dorm_expense,
COALESCE(e.delivery_commission_expense, 0) AS delivery_commission_expense,
COALESCE(e.card_fee_expense, 0) AS card_fee_expense,
COALESCE(e.repair_clean_expense, 0) AS repair_clean_expense,
COALESCE(e.operating_expense, 0) AS operating_expense
FROM analytics.mv_store_risk_rating r
LEFT JOIN analytics.mv_store_operating_expense_monthly e
ON r.store_code = e.sales_store_code AND e.report_month = DATE '2026-04-01'
WHERE r.received IS NOT NULL
)
SELECT
round(sum(received)::numeric, 2) AS received,
round(sum(actual_food_cost)::numeric, 2) AS food_cost,
round(sum(wage_expense)::numeric, 2) AS wage,
round(sum(rent_expense)::numeric, 2) AS rent,
round(sum(utility_expense)::numeric, 2) AS utility,
round(sum(dorm_expense)::numeric, 2) AS dorm,
round(sum(delivery_commission_expense)::numeric, 2) AS commission,
round(sum(card_fee_expense + repair_clean_expense)::numeric, 2) AS other_expense,
round(sum(operating_expense)::numeric, 2) AS total_expense,
round(sum(received) - sum(actual_food_cost) - sum(operating_expense), 2) AS net_profit
FROM full_scope
`)
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
export default router