167 lines
8.1 KiB
TypeScript
167 lines
8.1 KiB
TypeScript
import { useQuery } 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 { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||
import { useState } from 'react'
|
||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||
import { MonthPicker } from '@/components/MonthPicker'
|
||
|
||
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
|
||
|
||
export function SKUPage() {
|
||
const [month, setMonth] = useState('2026-04')
|
||
|
||
const { data: skuData, isLoading: skuLoading } = useQuery({
|
||
queryKey: ['sku/abc', month],
|
||
queryFn: () => api.get('/sku/abc', { params: { month } }),
|
||
})
|
||
|
||
const { data: catData, isLoading: catLoading } = useQuery({
|
||
queryKey: ['sku/category'],
|
||
queryFn: () => api.get('/sku/category'),
|
||
})
|
||
|
||
const skuRows = ((skuData as any)?.data || [])
|
||
const catRows = (catData as any)?.data || []
|
||
|
||
const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => {
|
||
const k = r.abc_class
|
||
if (!acc[k]) acc[k] = { sku_count: 0, received: 0, share: 0 }
|
||
acc[k].sku_count++
|
||
acc[k].received += Number(r.received_amount || 0)
|
||
acc[k].share += Number(r.revenue_share_pct || 0)
|
||
return acc
|
||
}, {}) || {}
|
||
|
||
const abcPieData = Object.entries(abcSummary).map(([name, v]: any) => ({
|
||
name,
|
||
value: v.sku_count,
|
||
received: v.received,
|
||
share: v.share,
|
||
}))
|
||
|
||
const totalSku = (skuData as any)?.data?.length || 0
|
||
const totalReceived = Object.values(abcSummary).reduce((s: number, v: any) => s + v.received, 0)
|
||
const cClassCount = abcSummary['C-长尾']?.sku_count || 0
|
||
const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length
|
||
const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length
|
||
|
||
const pageLoading = skuLoading || catLoading
|
||
|
||
if (pageLoading) {
|
||
return <LoadingSpinner text="加载SKU数据..." />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-bold">商品SKU管理</h1>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">SKU ABC分析 · 长尾治理 · 品类结构 · {month}</p>
|
||
</div>
|
||
<MonthPicker month={month} onChange={setMonth} />
|
||
</div>
|
||
|
||
{/* 概览指标 */}
|
||
<CollapsibleSection title="SKU概览" subtitle="商品复杂度与销售集中度">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="总SKU数" value={totalSku} format="number" description="所有有销售记录的菜品名称数量" />
|
||
<MetricCard title="A类SKU(核心)" value={abcSummary['A-核心']?.sku_count || 0} format="number" description="贡献约70%实收的头部SKU" />
|
||
<MetricCard title="C类SKU(长尾)" value={cClassCount} format="number" description="仅贡献10%实收的长尾SKU,建议治理" />
|
||
<MetricCard title="低价值SKU(<¥1000)" value={lowValueSku} format="number" description="月实收不足1000元的SKU,优先停用候选" />
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* ABC分布图 */}
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<CollapsibleSection title="SKU ABC分布" subtitle="按实收贡献分层">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<PieChart>
|
||
<Pie
|
||
data={abcPieData}
|
||
cx="50%" cy="50%" outerRadius={80}
|
||
dataKey="value"
|
||
label={({ name, value, share }: any) => `${name}: ${value}个 (${Number(share).toFixed(1)}%)`}
|
||
>
|
||
{abcPieData.map((entry) => (
|
||
<Cell key={entry.name} fill={ABC_COLORS[entry.name as keyof typeof ABC_COLORS] || '#gray'} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip formatter={(v: any, _n: any, p: any) => [`${v}个SKU, 实收¥${formatNumber(p.payload.received)}`, p.payload.name]} />
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
<CollapsibleSection title="品类实收分布" subtitle="按一级品类统计">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<BarChart data={catRows.slice(0, 10)} layout="vertical" margin={{ left: 80 }}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis type="number" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} />
|
||
<YAxis type="category" dataKey="category_name" tick={{ fontSize: 10 }} width={80} />
|
||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||
<Bar dataKey="amount" fill="#3b82f6" name="实收" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
</div>
|
||
|
||
{/* SKU明细表 */}
|
||
<CollapsibleSection title={`SKU明细 (${skuRows.length})`} subtitle="可按ABC分类筛选,点击表头排序">
|
||
<FilterableTable
|
||
data={skuRows}
|
||
sortOptions={[
|
||
{ key: 'received_amount', label: '实收' },
|
||
{ key: 'bill_count', label: '账单数' },
|
||
{ key: 'revenue_share_pct', label: '占比' },
|
||
{ key: 'store_count', label: '覆盖门店' },
|
||
{ key: 'dish_name', label: '菜品名称' },
|
||
]}
|
||
defaultSort="received_amount"
|
||
defaultOrder="desc"
|
||
filterKey="abc_class"
|
||
filterLabel="全部ABC"
|
||
columns={[
|
||
{ key: 'dish_name', label: '菜品名称' },
|
||
{ key: 'category_level1', label: '一级品类' },
|
||
{ key: 'abc_class', label: 'ABC', render: (r) => (
|
||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
|
||
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
|
||
'bg-red-100 text-red-700'
|
||
}`}>{r.abc_class}</span>
|
||
)},
|
||
{ key: 'sales_quadrant', label: '象限' },
|
||
{ key: 'store_count', label: '覆盖门店', align: 'center' },
|
||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||
{ key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) },
|
||
{ key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` },
|
||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` },
|
||
]}
|
||
/>
|
||
</CollapsibleSection>
|
||
|
||
{/* 长尾治理建议 */}
|
||
<CollapsibleSection title="长尾治理建议" subtitle="基于SKU分布的治理方向" defaultOpen={false}>
|
||
<div className="space-y-3 text-sm">
|
||
<div className="rounded-md border border-red-200 bg-red-50/50 p-3">
|
||
<p className="font-medium text-red-700">首轮停用候选:约 {lowValueSku} 个SKU</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件</p>
|
||
</div>
|
||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
|
||
<p className="font-medium text-yellow-700">单店专属SKU:{singleStoreSku} 个</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">仅1家门店销售,评估是否为区域特色或可推广</p>
|
||
</div>
|
||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||
<p className="font-medium text-blue-700">合并/归档候选:约300个套餐/赠品/餐具类</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">转为技术SKU或合并为统一编码,目标有效SKU池800-900个</p>
|
||
</div>
|
||
</div>
|
||
</CollapsibleSection>
|
||
</div>
|
||
)
|
||
}
|