feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询

This commit is contained in:
freedakgmail
2026-08-01 13:10:22 +08:00
parent a90c133578
commit dab06ba109
43 changed files with 9135 additions and 309 deletions
+99 -15
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
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'
@@ -6,10 +7,61 @@ 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'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: any; opp: number; pct: number; confColor: string }) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`rounded-lg border transition-all ${expanded ? 'border-purple-300 shadow-sm' : ''}`}>
<div
className="flex cursor-pointer items-center gap-3 p-3"
onClick={() => setExpanded(!expanded)}
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-sm font-bold text-purple-600">{index + 1}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{o.category}</span>
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${confColor}`}>{o.confidence}</span>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
线: {typeof o.baseline === 'number' ? (o.baseline < 100 ? formatPercent(o.baseline) : formatNumber(o.baseline)) : o.baseline}
{typeof o.baseline === 'number' && o.baseline < 100 ? '%' : ''} · : {o.owner} · : {o.evidence}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-green-600">{formatCurrency(opp)}</p>
<p className="text-[10px] text-muted-foreground"> {formatPercent(pct)}</p>
</div>
</div>
{expanded && o.detail && (
<div className="border-t bg-purple-50/20 px-4 py-3">
{o.detail.split('\n').map((line: string, idx: number) => {
const isHeader = line.endsWith('') || line.endsWith(':')
const isAction = line.startsWith('行动指向') || /^\d/.test(line)
const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入')
return (
<p
key={idx}
className={`text-xs leading-relaxed ${
isHeader ? 'mt-2 font-semibold text-foreground' :
isAction ? 'text-foreground' :
isStoreLine ? 'text-muted-foreground' :
'text-muted-foreground'
}`}
>
{line || '\u00A0'}
</p>
)
})}
</div>
)}
</div>
)
}
export function BossPage() {
const navigate = useNavigate()
@@ -49,6 +101,10 @@ export function BossPage() {
queryKey: ['overview/store-profit-ranking'],
queryFn: () => api.get('/overview/store-profit-ranking'),
})
const { data: profitOppData } = useQuery({
queryKey: ['overview/profit-opportunity'],
queryFn: () => api.get('/overview/profit-opportunity'),
})
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
@@ -61,6 +117,8 @@ export function BossPage() {
const costOv = (costOverview as any)?.data || {}
const structRows = (expenseStructure as any)?.data || []
const profitRanking = (profitRankingData as any)?.data || []
const profitOpp = (profitOppData as any)?.data?.items || []
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Number(o.opportunity || 0), 0)
// 风险分布
const riskSummary = riskRows.reduce((acc: any, r: any) => {
@@ -101,7 +159,7 @@ export function BossPage() {
{ 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' },
{ name: '门店贡献利润', value: Number(wf.store_contribution), type: 'profit' },
] : []
// 瀑布图累计计算
@@ -156,14 +214,14 @@ export function BossPage() {
{/* ① 核心经营指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="实收总额" value={ex?.total_received} format="currency" trend={trendReceived} description={`全口径${ex?.total_stores}家门店。公式:全部门店实收合计。环比上周 ${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={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={ex?.total_stores} unit="家" description={`盈利 ${ex?.profitable_stores}家 / 亏损 ${ex?.loss_stores}家。无费用数据 ${ex?.no_expense_stores}`} />
<MetricCard title="客单价" value={ex?.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(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
</div>
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 净利润">
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
@@ -189,8 +247,8 @@ export function BossPage() {
</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>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.store_contribution)}</p>
</div>
</div>
{/* 成本费用占比汇总条 */}
@@ -213,20 +271,46 @@ export function BossPage() {
<div className="flex items-center justify-center bg-slate-400" style={{ width: `${(Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100}%` }} title={`其他费用 ${formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}`}>
{formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}
</div>
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.net_profit) / Number(wf.received) * 100}%` }} title={`利润 ${formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.store_contribution) / Number(wf.received) * 100}%` }} title={`门店贡献利润 ${formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}`}>
{formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
<span> {formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)} 32%</span>
<span> {formatPercent(Number(wf.total_expense) / Number(wf.received) * 100)} 40%</span>
<span> {formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)} 10%</span>
<span> {formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)} 10%</span>
</div>
</div>
)}
</CollapsibleSection>
)}
{/* ②b 利润机会池 */}
{profitOpp.length > 0 && (
<CollapsibleSection title="利润机会池" subtitle={`理论月度机会合计 ${formatCurrency(totalOpportunity)} · 30天承诺值 ≥ 150万元 · 点击展开详情`}>
<div className="space-y-2">
{[...profitOpp].sort((a: any, b: any) => Number(b.opportunity || 0) - Number(a.opportunity || 0)).map((o: any, i: number) => {
const opp = Number(o.opportunity || 0)
const pct = totalOpportunity > 0 ? (opp / totalOpportunity * 100) : 0
const confColor = o.confidence === '中高' ? 'text-green-600 bg-green-50' : o.confidence === '中' ? 'text-blue-600 bg-blue-50' : 'text-yellow-600 bg-yellow-50'
return (
<ProfitOppItem key={i} index={i} o={o} opp={opp} pct={pct} confColor={confColor} />
)
})}
</div>
<div className="mt-3 rounded-lg border border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">30</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{formatCurrency(totalOpportunity)} <span className="font-bold text-purple-600"> 150</span>
7.29% 10%
</p>
</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'}`}>
@@ -358,7 +442,7 @@ export function BossPage() {
{/* ⑤b 门店利润排名 */}
{profitRanking.length > 0 && (
<CollapsibleSection title="门店利润排名" subtitle="TOP5 最赚钱 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用">
<CollapsibleSection title="门店利润排名" subtitle="TOP5 贡献最高 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)">
<div className="grid gap-4 lg:grid-cols-2">
{/* TOP5 */}
<div>
@@ -373,9 +457,9 @@ export function BossPage() {
<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>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.net_profit)}</span>
<span className="text-lg font-bold text-green-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>
@@ -384,7 +468,7 @@ export function BossPage() {
<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) => (
{profitRanking.filter((s: any) => Number(s.store_contribution) < 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"
@@ -393,9 +477,9 @@ export function BossPage() {
<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>
<p className="text-xs text-muted-foreground"> {formatCurrency(s.received)} · {formatPercent(s.contribution_margin_pct)}</p>
</div>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.net_profit)}</span>
<span className="text-lg font-bold text-red-700">{formatCurrency(s.store_contribution)}</span>
</div>
))}
</div>