feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user