V3.0: 门店分级与风险等级页面、数据填充脚本、后端API

- 新增V3.0数据库表结构(12-15)和数据填充脚本(16)
- 新增后端路由: store-grade, enterprise, product, intelligence, alert, scheduler, target
- 新增前端页面: StoreGradePage, EnterprisePage, ProductLifecyclePage, IntelligencePage, AlertManagementPage, DataImportPage, SchedulerManagementPage, TargetManagementPage
- 门店分级: 自动分级算法(达成率40%+毛利率25%+会员15%+风险20%)
- 风险等级: 基于mv_store_risk_rating模型展示风险分布和原因
- 弹窗: 雷达图+详细指标+分级原因+风险原因(primary_issue)
- FilterableTable: filterOptions支持{value,label}对象格式
This commit is contained in:
freedakgmail
2026-08-05 20:09:39 +08:00
parent 814abf4a68
commit d04e837d9c
30 changed files with 5052 additions and 144 deletions
+321
View File
@@ -0,0 +1,321 @@
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'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
export function TargetManagementPage() {
const [tab, setTab] = useState('overview')
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
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>
<div className="flex items-center gap-2">
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-20 rounded border px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground"></span>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
</div>
</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>
)
}