9b3a9cdb4b
- 库存周转: turnover_days零消耗时返回NULL并显示"无消耗",排序至末尾; 门店名称三级回退(dim_store→mv_distribution_monthly→硬编码中央厨房) - 成本管理: estimated_inventory_days为NULL时显示"-"而非"0.0天"; 补充成本率口径与指标关系说明 - 成本分析: 修复理论成本率计算(按单位售价而非单价),新增sales_quantity展示; 区分菜品级(Excel)与门店级(倒挤)成本指标,说明差异原因 - 配送对账: 区分中央厨房/门店,分层展示耗用口径 - 告警/任务/目标管理: 新增详情弹窗组件,优化交互体验 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
309 lines
16 KiB
TypeScript
309 lines
16 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import api from '@/lib/api'
|
|
import { FilterableTable } from '@/components/FilterableTable'
|
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
|
import { MetricCard } from '@/components/MetricCard'
|
|
import { Tabs } from '@/components/Tabs'
|
|
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
|
import { useState } from 'react'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
|
import { MonthPicker } from '@/components/MonthPicker'
|
|
|
|
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
|
|
|
|
export function TargetManagementPage() {
|
|
const [tab, setTab] = useState('overview')
|
|
const [month, setMonth] = useState('2026-04')
|
|
const year = parseInt(month.split('-')[0])
|
|
const queryClient = useQueryClient()
|
|
|
|
const { data: annualData, isLoading: annualLoading } = useQuery({
|
|
queryKey: ['target/annual', year],
|
|
queryFn: () => api.get('/target/annual', { params: { year } }),
|
|
})
|
|
|
|
const { data: regionalData, isLoading: regionalLoading } = useQuery({
|
|
queryKey: ['target/regional', year],
|
|
queryFn: () => api.get('/target/regional', { params: { year } }),
|
|
})
|
|
|
|
const { data: storeMonthlyData, isLoading: storeLoading } = useQuery({
|
|
queryKey: ['target/store-monthly', month],
|
|
queryFn: () => api.get('/target/store-monthly', { params: { month: month + '-01' } }),
|
|
})
|
|
|
|
const { data: achievementData, isLoading: achievementLoading } = useQuery({
|
|
queryKey: ['target/achievement', month],
|
|
queryFn: () => api.get('/target/achievement', { params: { month: month + '-01' } }),
|
|
})
|
|
|
|
const decomposeMutation = useMutation({
|
|
mutationFn: (data: { year: number; month?: string }) =>
|
|
api.post('/target/decompose/annual-to-store', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['target/store-monthly'] })
|
|
queryClient.invalidateQueries({ queryKey: ['target/achievement'] })
|
|
},
|
|
})
|
|
|
|
const dailyDecomposeMutation = useMutation({
|
|
mutationFn: (data: { month: string }) =>
|
|
api.post('/target/decompose/monthly-to-daily', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['target/daily'] })
|
|
},
|
|
})
|
|
|
|
const annualRows = (annualData as any)?.data || []
|
|
const regionalRows = (regionalData as any)?.data || []
|
|
const storeRows = (storeMonthlyData as any)?.data || []
|
|
const achievementRows = (achievementData as any)?.data || []
|
|
|
|
const revenueTarget = annualRows.find((r: any) => r.metric === 'revenue')
|
|
const profitTarget = annualRows.find((r: any) => r.metric === 'profit')
|
|
const storeCountTarget = annualRows.find((r: any) => r.metric === 'store_count')
|
|
const memberCountTarget = annualRows.find((r: any) => r.metric === 'member_count')
|
|
|
|
const totalStoreTarget = storeRows.reduce((s: number, r: any) => s + Number(r.revenue_target || 0), 0)
|
|
const avgAchievement = achievementRows.length > 0
|
|
? achievementRows.reduce((s: number, r: any) => s + Number(r.achievement_rate || 0), 0) / achievementRows.length
|
|
: 0
|
|
const achievedCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) >= 100).length
|
|
const lowAchievementCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) < 80).length
|
|
|
|
const gradeDistribution = storeRows.reduce((acc: Record<string, number>, r: any) => {
|
|
const g = r.grade || 'B'
|
|
acc[g] = (acc[g] || 0) + 1
|
|
return acc
|
|
}, {})
|
|
const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}级`, value }))
|
|
|
|
const achievementChartData = achievementRows.slice(0, 20).map((r: any) => ({
|
|
name: r.store_name?.length > 6 ? r.store_name.slice(0, 6) : r.store_name,
|
|
达成率: Number(r.achievement_rate || 0),
|
|
}))
|
|
|
|
const pageLoading = annualLoading || regionalLoading || storeLoading || achievementLoading
|
|
|
|
if (pageLoading && tab === 'overview') {
|
|
return <LoadingSpinner text="加载目标管理数据..." />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">目标管理</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">年度战略目标 · 区域拆解 · 门店月度 · 达成率追踪</p>
|
|
</div>
|
|
<MonthPicker month={month} onChange={setMonth} />
|
|
</div>
|
|
|
|
<Tabs
|
|
tabs={[
|
|
{ key: 'overview', label: '目标概览' },
|
|
{ key: 'annual', label: '年度目标' },
|
|
{ key: 'regional', label: '区域拆解' },
|
|
{ key: 'store', label: '门店月度' },
|
|
{ key: 'achievement', label: '达成率' },
|
|
]}
|
|
active={tab}
|
|
onChange={setTab}
|
|
/>
|
|
|
|
{tab === 'overview' && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<MetricCard title="年度营收目标" value={revenueTarget?.target_value || 0} format="currency" description={`${year}年年度战略营收目标`} />
|
|
<MetricCard title="年度利润目标" value={profitTarget?.target_value || 0} format="currency" description={`${year}年年度战略利润目标`} />
|
|
<MetricCard title="门店月度目标总额" value={totalStoreTarget} format="currency" description={`${month}月所有门店营收目标合计`} />
|
|
<MetricCard title="平均达成率" value={avgAchievement} format="percent" description="所有门店达成率的平均值" status={avgAchievement >= 90 ? 'good' : avgAchievement >= 70 ? 'warn' : 'bad'} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<MetricCard title="目标门店数" value={storeCountTarget?.target_value || 0} format="number" description={`${year}年目标门店数`} />
|
|
<MetricCard title="目标会员数" value={memberCountTarget?.target_value || 0} format="number" description={`${year}年目标会员数`} />
|
|
<MetricCard title="达标门店数" value={achievedCount} format="number" description="达成率≥100%的门店数" status="good" />
|
|
<MetricCard title="低达成门店(<80%)" value={lowAchievementCount} format="number" description="达成率<80%的门店数,需重点关注" status={lowAchievementCount > 0 ? 'bad' : 'good'} />
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<CollapsibleSection title="门店达成率TOP20" subtitle="按达成率降序">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart data={achievementChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-30} textAnchor="end" height={60} />
|
|
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
|
<Tooltip formatter={(v: any) => [`${Number(v).toFixed(1)}%`, '达成率']} />
|
|
<Legend />
|
|
<Bar dataKey="达成率" fill="#3b82f6" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection title="门店分级分布" subtitle="A/B/C/D级门店数量">
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<PieChart>
|
|
<Pie data={gradePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
|
|
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
|
|
{gradePieData.map((entry, i) => <Cell key={i} fill={GRADE_COLORS[entry.name[0]] || '#999'} />)}
|
|
</Pie>
|
|
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
|
|
<Legend />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
<CollapsibleSection title="目标拆解操作" subtitle="自动拆解年度目标到门店月度">
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => decomposeMutation.mutate({ year, month: month + '-01' })}
|
|
disabled={decomposeMutation.isPending}
|
|
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
|
>
|
|
{decomposeMutation.isPending ? '拆解中...' : `年度→门店月度拆解 (${month})`}
|
|
</button>
|
|
<button
|
|
onClick={() => dailyDecomposeMutation.mutate({ month: month + '-01' })}
|
|
disabled={dailyDecomposeMutation.isPending}
|
|
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
|
>
|
|
{dailyDecomposeMutation.isPending ? '拆解中...' : `月度→日目标拆解 (${month})`}
|
|
</button>
|
|
{decomposeMutation.data && (
|
|
<span className="self-center text-sm text-green-600">
|
|
拆解完成: {(decomposeMutation.data as any)?.data?.stores || 0}家门店
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CollapsibleSection>
|
|
</>
|
|
)}
|
|
|
|
{tab === 'annual' && (
|
|
<CollapsibleSection title="年度战略目标" subtitle={`${year}年战略目标设定`}>
|
|
<FilterableTable
|
|
data={annualRows}
|
|
sortOptions={[
|
|
{ key: 'metric', label: '指标' },
|
|
{ key: 'target_value', label: '目标值' },
|
|
]}
|
|
defaultSort="metric"
|
|
defaultOrder="asc"
|
|
columns={[
|
|
{ key: 'year', label: '年份', align: 'center' },
|
|
{ key: 'metric', label: '指标', render: (r) => {
|
|
const labels: Record<string, string> = { revenue: '营收', profit: '利润', store_count: '门店数', member_count: '会员数' }
|
|
return labels[r.metric] || r.metric
|
|
}},
|
|
{ key: 'target_value', label: '目标值', align: 'right', render: (r) => {
|
|
if (r.metric === 'revenue' || r.metric === 'profit') return formatCurrency(r.target_value)
|
|
return formatNumber(r.target_value)
|
|
}},
|
|
{ key: 'description', label: '说明' },
|
|
{ key: 'created_by', label: '创建人' },
|
|
]}
|
|
/>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{tab === 'regional' && (
|
|
<CollapsibleSection title="区域年度目标" subtitle={`${year}年各区域目标拆解`}>
|
|
<FilterableTable
|
|
data={regionalRows}
|
|
sortOptions={[
|
|
{ key: 'region', label: '区域' },
|
|
{ key: 'revenue_target', label: '营收目标' },
|
|
{ key: 'weight_pct', label: '权重' },
|
|
]}
|
|
defaultSort="region"
|
|
defaultOrder="asc"
|
|
columns={[
|
|
{ key: 'region', label: '区域' },
|
|
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
|
|
{ key: 'profit_target', label: '利润目标', align: 'right', render: (r) => formatCurrency(r.profit_target) },
|
|
{ key: 'store_count_target', label: '门店数目标', align: 'right', render: (r) => formatNumber(r.store_count_target) },
|
|
{ key: 'member_count_target', label: '会员数目标', align: 'right', render: (r) => formatNumber(r.member_count_target) },
|
|
{ key: 'weight_pct', label: '权重', align: 'right', render: (r) => formatPercent(r.weight_pct) },
|
|
]}
|
|
/>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{tab === 'store' && (
|
|
<CollapsibleSection title={`门店月度目标 (${storeRows.length})`} subtitle={`${month}月各门店目标`}>
|
|
<FilterableTable
|
|
data={storeRows}
|
|
filterKey="region"
|
|
filterLabel="全部区域"
|
|
sortOptions={[
|
|
{ key: 'store_code', label: '门店编码' },
|
|
{ key: 'revenue_target', label: '营收目标' },
|
|
{ key: 'grade', label: '分级' },
|
|
]}
|
|
defaultSort="store_code"
|
|
defaultOrder="asc"
|
|
columns={[
|
|
{ key: 'store_code', label: '门店编码' },
|
|
{ key: 'store_name', label: '门店名称' },
|
|
{ key: 'region', label: '区域' },
|
|
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
|
|
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
|
|
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
|
|
}},
|
|
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
|
|
{ key: 'bill_count_target', label: '账单数目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) },
|
|
{ key: 'avg_bill_value_target', label: '客单价目标', align: 'right', render: (r) => formatCurrency(r.avg_bill_value_target) },
|
|
{ key: 'member_penetration_target', label: '会员渗透率', align: 'right', render: (r) => formatPercent(r.member_penetration_target) },
|
|
{ key: 'cost_rate_target', label: '成本率', align: 'right', render: (r) => formatPercent(r.cost_rate_target) },
|
|
]}
|
|
/>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{tab === 'achievement' && (
|
|
<CollapsibleSection title={`门店达成率 (${achievementRows.length})`} subtitle={`${month}月各门店目标达成率`}>
|
|
{achievementLoading ? <LoadingSpinner text="加载达成率数据..." /> : (
|
|
<FilterableTable
|
|
data={achievementRows}
|
|
filterKey="region"
|
|
filterLabel="全部区域"
|
|
sortOptions={[
|
|
{ key: 'achievement_rate', label: '达成率' },
|
|
{ key: 'revenue_target', label: '目标' },
|
|
{ key: 'actual_revenue', label: '实际' },
|
|
{ key: 'bill_achievement_rate', label: '账单达成率' },
|
|
]}
|
|
defaultSort="achievement_rate"
|
|
defaultOrder="desc"
|
|
columns={[
|
|
{ key: 'store_code', label: '门店编码' },
|
|
{ key: 'store_name', label: '门店名称' },
|
|
{ key: 'region', label: '区域' },
|
|
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
|
|
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
|
|
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
|
|
}},
|
|
{ key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) },
|
|
{ key: 'actual_revenue', label: '实际营收', align: 'right', render: (r) => formatCurrency(r.actual_revenue) },
|
|
{ key: 'achievement_rate', label: '达成率', align: 'right', render: (r) => {
|
|
const v = Number(r.achievement_rate || 0)
|
|
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
|
|
}},
|
|
{ key: 'bill_count_target', label: '账单目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) },
|
|
{ key: 'actual_bill_count', label: '实际账单', align: 'right', render: (r) => formatNumber(r.actual_bill_count) },
|
|
{ key: 'bill_achievement_rate', label: '账单达成率', align: 'right', render: (r) => {
|
|
const v = Number(r.bill_achievement_rate || 0)
|
|
return <span className={v >= 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
|
|
}},
|
|
]}
|
|
/>
|
|
)}
|
|
</CollapsibleSection>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|