初始化:连锁餐饮数字化运营管理平台
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
export function MemberPage() {
|
||||
const [repeatPage, setRepeatPage] = useState(1)
|
||||
|
||||
const { data: comparisonData, isLoading: cmpLoading } = useQuery({
|
||||
queryKey: ['member/comparison'],
|
||||
queryFn: () => api.get('/member/comparison'),
|
||||
})
|
||||
|
||||
const { data: repeatData, isLoading: repeatLoading } = useQuery({
|
||||
queryKey: ['member/repeat'],
|
||||
queryFn: () => api.get('/member/repeat'),
|
||||
})
|
||||
|
||||
const cmpRows = (comparisonData as any)?.data || []
|
||||
const repeatRows = (repeatData as any)?.data || []
|
||||
|
||||
const pageLoading = cmpLoading || repeatLoading
|
||||
|
||||
const memberRow = cmpRows.find((r: any) => r.customer_type === '会员') || {}
|
||||
const nonMemberRow = cmpRows.find((r: any) => r.customer_type === '非会员') || {}
|
||||
|
||||
const totalBills = Number(memberRow.bill_count || 0) + Number(nonMemberRow.bill_count || 0)
|
||||
const memberBillShare = totalBills > 0 ? (Number(memberRow.bill_count || 0) / totalBills * 100) : 0
|
||||
const totalReceived = Number(memberRow.received || 0) + Number(nonMemberRow.received || 0)
|
||||
const memberRevenueShare = totalReceived > 0 ? (Number(memberRow.received || 0) / totalReceived * 100) : 0
|
||||
|
||||
const avgRepeatRate = repeatRows.length > 0
|
||||
? repeatRows.reduce((s: number, r: any) => s + Number(r.repeat_rate_pct || 0), 0) / repeatRows.length
|
||||
: 0
|
||||
const topRepeatStore = repeatRows.length > 0
|
||||
? repeatRows.reduce((best: any, r: any) => Number(r.repeat_rate_pct || 0) > Number(best?.repeat_rate_pct || 0) ? r : best, null)
|
||||
: null
|
||||
const lowRepeatCount = repeatRows.filter((r: any) => Number(r.repeat_rate_pct || 0) < 30).length
|
||||
|
||||
const cmpChartData = cmpRows.map((r: any) => ({
|
||||
name: r.customer_type,
|
||||
账单数: Number(r.bill_count || 0),
|
||||
实收: Number(r.received || 0),
|
||||
客单价: Number(r.avg_bill_value || 0),
|
||||
}))
|
||||
|
||||
const pieData = [
|
||||
{ name: '会员账单', value: Number(memberRow.bill_count || 0) },
|
||||
{ name: '非会员账单', value: Number(nonMemberRow.bill_count || 0) },
|
||||
]
|
||||
const PIE_COLORS = ['#3b82f6', '#e5e7eb']
|
||||
|
||||
const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage])
|
||||
|
||||
if (pageLoading) {
|
||||
return <LoadingSpinner text="加载会员数据..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">会员与复购管理</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">会员对比 · 复购率 · 沉睡分析 · 2026年4月</p>
|
||||
</div>
|
||||
|
||||
{/* 概览指标 */}
|
||||
<CollapsibleSection title="会员概览" subtitle="会员渗透与消费对比">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="会员账单占比" value={memberBillShare} format="percent" description="会员账单数占总账单数的比例" />
|
||||
<MetricCard title="会员收入占比" value={memberRevenueShare} format="percent" description="会员实收金额占总实收的比例" />
|
||||
<MetricCard title="平均复购率" value={avgRepeatRate} format="percent" description="所有门店30日复购率的平均值" />
|
||||
<MetricCard title="低复购门店(<30%)" value={lowRepeatCount} format="number" description="复购率低于30%的门店数,需重点改善" />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 会员对比图 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="会员 vs 非会员消费对比" subtitle="账单数、实收、客单价对比">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={cmpChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any, name: any) => name === '实收' ? formatCurrency(v) : name === '客单价' ? formatCurrency(v) : formatNumber(v)} />
|
||||
<Legend />
|
||||
<Bar yAxisId="left" dataKey="账单数" fill="#3b82f6" />
|
||||
<Bar yAxisId="left" dataKey="实收" fill="#22c55e" />
|
||||
<Bar yAxisId="right" dataKey="客单价" fill="#eab308" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="会员账单占比" subtitle="会员与非会员账单分布">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
||||
label={({ name, value, }: any) => `${name}: ${formatNumber(value)}`}>
|
||||
{pieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* 会员对比明细 */}
|
||||
<CollapsibleSection title="会员与非会员指标对比" subtitle="优惠率、毛利率等关键指标">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'customer_type', label: '客户类型' },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
|
||||
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) },
|
||||
{ key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) },
|
||||
]}
|
||||
data={cmpRows}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店复购率 */}
|
||||
<CollapsibleSection title={`门店复购率 (${repeatRows.length})`} subtitle="按复购率降序,低于30%需重点改善">
|
||||
<Pagination page={repeatPage} pageSize={PAGE_SIZE} total={repeatRows.length} onPageChange={setRepeatPage} />
|
||||
<div className="mt-3">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'identified_members', label: '识别会员数', align: 'right', render: (r) => formatNumber(r.identified_members) },
|
||||
{ key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) },
|
||||
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => {
|
||||
const v = Number(r.repeat_rate_pct || 0)
|
||||
return <span className={v >= 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
|
||||
}},
|
||||
{ key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) },
|
||||
{ key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` },
|
||||
]}
|
||||
data={pagedRepeat}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user