Files
SBrainCO/client/src/pages/SKUPage.tsx
T

167 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,000SKU</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>
)
}