初始化:连锁餐饮数字化运营管理平台

This commit is contained in:
freedakgmail
2026-07-26 22:48:08 +08:00
commit a7874d79b5
67 changed files with 14391 additions and 0 deletions
+170
View File
@@ -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分析 · · · 20264</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,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>
)
}