初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||
|
||||
const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' }
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function SKUPage() {
|
||||
const [abcFilter, setAbcFilter] = useState('')
|
||||
const [skuPage, setSkuPage] = useState(1)
|
||||
|
||||
const { data: skuData, isLoading: skuLoading } = useQuery({
|
||||
queryKey: ['sku/abc'],
|
||||
queryFn: () => api.get('/sku/abc'),
|
||||
})
|
||||
|
||||
const { data: catData, isLoading: catLoading } = useQuery({
|
||||
queryKey: ['sku/category'],
|
||||
queryFn: () => api.get('/sku/category'),
|
||||
})
|
||||
|
||||
const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter)
|
||||
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 pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage])
|
||||
|
||||
const pageLoading = skuLoading || catLoading
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载SKU数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">商品SKU管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">SKU ABC分析 · 长尾治理 · 品类结构 · 2026年4月</p>
|
||||
</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分类筛选,点击表头排序">
|
||||
<div className="mb-3 flex gap-2">
|
||||
{['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => (
|
||||
<button
|
||||
key={f || 'all'}
|
||||
onClick={() => { setAbcFilter(f); setSkuPage(1) }}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm ${abcFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
|
||||
>
|
||||
{f || '全部'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={skuPage} pageSize={PAGE_SIZE} total={skuRows.length} onPageChange={setSkuPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
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)}%` },
|
||||
]}
|
||||
data={pagedSku}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user