feat: 老板驾驶舱增加门店利润排名 - TOP5赚钱/BOTTOM5亏损,可点击跳转门店详情

This commit is contained in:
freedakgmail
2026-07-30 14:37:38 +08:00
parent 10c64a2ef2
commit 2df4f0799b
2 changed files with 78 additions and 0 deletions
+53
View File
@@ -45,6 +45,10 @@ export function BossPage() {
queryKey: ['store-expense/expense-structure'],
queryFn: () => api.get('/store-expense/expense-structure'),
})
const { data: profitRankingData } = useQuery({
queryKey: ['overview/store-profit-ranking'],
queryFn: () => api.get('/overview/store-profit-ranking'),
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
@@ -56,6 +60,7 @@ export function BossPage() {
const dailyRows = (daily as any)?.data || []
const costOv = (costOverview as any)?.data || {}
const structRows = (expenseStructure as any)?.data || []
const profitRanking = (profitRankingData as any)?.data || []
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
@@ -351,6 +356,54 @@ export function BossPage() {
</ResponsiveContainer>
</CollapsibleSection>
{/* ⑤b 门店利润排名 */}
{profitRanking.length > 0 && (
<CollapsibleSection title="门店利润排名" subtitle="TOP5 最赚钱 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用">
<div className="grid gap-4 lg:grid-cols-2">
{/* TOP5 */}
<div>
<p className="mb-2 text-sm font-bold text-green-600"> TOP5</p>
<div className="space-y-2">
{profitRanking.slice(0, 5).map((s: any, i: number) => (
<div
key={s.store_code}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-green-200 bg-green-50/30 p-3 hover:bg-green-50/60"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<span className="text-lg font-bold text-green-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.net_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.net_profit)}</span>
</div>
))}
</div>
</div>
{/* BOTTOM5 */}
<div>
<p className="mb-2 text-sm font-bold text-red-600"> BOTTOM5</p>
<div className="space-y-2">
{profitRanking.filter((s: any) => Number(s.net_profit) < 0).slice(-5).reverse().map((s: any, i: number) => (
<div
key={s.store_code}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-red-200 bg-red-50/30 p-3 hover:bg-red-50/60"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<span className="text-lg font-bold text-red-600">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{s.store_name}</p>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.net_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.net_profit)}</span>
</div>
))}
</div>
</div>
</div>
</CollapsibleSection>
)}
{/* ⑥ 日度实收趋势 */}
<CollapsibleSection title="日度实收趋势" subtitle={`近30天 · 环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`}>
<ResponsiveContainer width="100%" height={220}>
+25
View File
@@ -539,4 +539,29 @@ router.get('/overview/profit-waterfall', async (req: AuthRequest, res) => {
}
})
// 门店利润排名
router.get('/overview/store-profit-ranking', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT
r.store_code,
r.store_name,
round(r.received::numeric, 2) AS received,
round(COALESCE(e.actual_food_cost, 0)::numeric, 2) AS food_cost,
round(COALESCE(e.operating_expense, 0)::numeric, 2) AS expense,
round((r.received - COALESCE(e.actual_food_cost, 0) - COALESCE(e.operating_expense, 0))::numeric, 2) AS net_profit,
round((r.received - COALESCE(e.actual_food_cost, 0) - COALESCE(e.operating_expense, 0)) / nullif(r.received, 0) * 100, 2) AS net_margin_pct,
r.risk_level
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 AND r.received > 0
ORDER BY net_profit DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router