diff --git a/client/src/App.tsx b/client/src/App.tsx index e4d779b..9ad1f5f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 039dec5..7dbd58c 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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'] }, diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx new file mode 100644 index 0000000..bb8e34c --- /dev/null +++ b/client/src/pages/BossPage.tsx @@ -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 = { '红色': '#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 + } + + return ( +
+ {/* 标题 */} +
+ +
+

老板驾驶舱

+

经营全景 · 2026年4月

+
+
+ + {/* ① 核心经营指标 */} +
+ 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} /> + + + 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)} 笔 · 公式:实收 ÷ 账单数`} /> +
+ + {/* ② 利润瀑布 */} + {wf && ( + + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + formatCurrency(v)} /> + + + {waterfallChart.map((entry, i) => )} + + + +
+ {waterfallSteps.filter(s => s.type === 'cost').map(s => ( +
+

{s.name}

+

{formatCurrency(Math.abs(s.value))}

+
+ ))} +
+

净利润

+

{formatCurrency(wf.net_profit)}

+
+
+
+ )} + + {/* ③ 风险态势 */} +
+
15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40'}`}> +
+ + 红色门店 +
+

{riskSummary['红色'] || 0}

+

实收 {formatCurrency(riskRevSummary['红色'])} · 占比 {totalRev > 0 ? formatPercent(riskRevSummary['红色'] / totalRev * 100) : '-'}

+
+
+
+ + 黄色门店 +
+

{riskSummary['黄色'] || 0}

+

实收 {formatCurrency(riskRevSummary['黄色'])} · 占比 {totalRev > 0 ? formatPercent(riskRevSummary['黄色'] / totalRev * 100) : '-'}

+
+
+
+ + 绿色门店 +
+

{riskSummary['绿色'] || 0}

+

实收 {formatCurrency(riskRevSummary['绿色'])} · 占比 {totalRev > 0 ? formatPercent(riskRevSummary['绿色'] / totalRev * 100) : '-'}

+
+
+ + {/* P0/P1 门店清单 */} + {p0p1Stores.length > 0 && ( + +
+ + + + + + + + + + + + {p0p1Stores.map((s: any) => ( + navigate(`/stores/${s.store_code}`)} + > + + + + + + + ))} + +
门店实收优先级风险问题
{s.store_name}{formatCurrency(s.received)} + + {s.action_priority} + + + + {s.risk_level} + + {s.problem_combination || '-'}
+
+
+ )} + + {/* ④ 成本异常 + 费用结构 */} +
+ +
+
+

严重超耗

+

{costRed}

+

+
+
+

明显超耗

+

{costOrange}

+

+
+
+

基本正常

+

{costGreen}

+

+
+
+
+

总差异金额

+

{formatCurrency(costOv.total_variance)}

+

公式:实际食材成本 - 理论食材成本。正值表示超耗

+
+
+ + + {!structLoading && pieData.length > 0 && ( + + + `${e.name}`}> + {pieData.map((_: any, i: number) => )} + + formatCurrency(v)} /> + + + + )} + +
+ + {/* ⑤ 门店营收排名 */} + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + + formatCurrency(v)} /> + d.code && navigate(`/stores/${d.code}`)}> + {rankingData.map((entry, i) => ( + + ))} + + + + + + {/* ⑥ 日度实收趋势 */} + 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`}> + + + + v?.substring(5, 10)} tick={{ fontSize: 10 }} /> + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} /> + + + + +
+ ) +} diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index a6f837c..718e6f4 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -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