feat: 连锁餐饮数字本体与AI经营智脑 - 新增分析页面、ETL脚本、总结文档

This commit is contained in:
freedakgmail
2026-08-02 10:54:48 +08:00
parent 73f12f40d1
commit a1a8b7ee5e
19 changed files with 3453 additions and 2 deletions
+10
View File
@@ -31,6 +31,11 @@ import { DistributionReconciliationPage } from '@/pages/DistributionReconciliati
import { BomPenetrationPage } from '@/pages/BomPenetrationPage'
import { ProductionPlanPage } from '@/pages/ProductionPlanPage'
import { LoginPage } from '@/pages/LoginPage'
import { MemberLTVPage } from '@/pages/MemberLTVPage'
import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage'
import { RegionComparisonPage } from '@/pages/RegionComparisonPage'
import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
const queryClient = new QueryClient({
defaultOptions: {
@@ -102,6 +107,11 @@ export default function App() {
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
<Route path="/situational-awareness" element={<SituationalAwarenessPage />} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="/member-ltv" element={<MemberLTVPage />} />
<Route path="/menu-engineering" element={<MenuEngineeringPage />} />
<Route path="/region-comparison" element={<RegionComparisonPage />} />
<Route path="/employee-performance" element={<EmployeePerformancePage />} />
<Route path="/inventory-turnover" element={<InventoryTurnoverPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+123
View File
@@ -0,0 +1,123 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
interface KPISectionProps {
month: string
level: 'hq' | 'region' | 'store'
storeCode?: string
defaultOpen?: boolean
}
export function KPISection({ month, level, storeCode, defaultOpen = true }: KPISectionProps) {
const params: any = { month, level }
if (level === 'store' && storeCode) params.store_code = storeCode
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/kpi', month, level, storeCode],
queryFn: () => api.get('/analytics-enhanced/kpi', { params }),
})
if (isLoading) {
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<p className="py-8 text-center text-sm text-muted-foreground">...</p>
</CollapsibleSection>
)
}
const d = (data as any)?.data
if (level === 'region') {
const rows = Array.isArray(d) ? d : []
if (rows.length === 0) {
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<p className="py-8 text-center text-sm text-muted-foreground"></p>
</CollapsibleSection>
)
}
const chartData = rows.map((r: any) => ({
name: r.region,
收入目标: Number(r.revenue_target || 0),
收入实际: Number(r.actual_revenue || 0),
利润目标: Number(r.profit_target || 0),
利润实际: Number(r.actual_profit || 0),
收入达成率: Number(r.revenue_achievement_pct || 0),
利润达成率: Number(r.profit_achievement_pct || 0),
}))
const totalActualRevenue = rows.reduce((s: number, r: any) => s + Number(r.actual_revenue || 0), 0)
const totalTargetRevenue = rows.reduce((s: number, r: any) => s + Number(r.revenue_target || 0), 0)
const totalActualProfit = rows.reduce((s: number, r: any) => s + Number(r.actual_profit || 0), 0)
const totalTargetProfit = rows.reduce((s: number, r: any) => s + Number(r.profit_target || 0), 0)
const revAchievement = totalTargetRevenue > 0 ? totalActualRevenue / totalTargetRevenue * 100 : 0
const profitAchievement = totalTargetProfit > 0 ? totalActualProfit / totalTargetProfit * 100 : 0
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标 · 各区域对比" defaultOpen={defaultOpen}>
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="收入达成率" value={revAchievement} format="percent" status={revAchievement >= 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" />
<MetricCard title="利润达成率" value={profitAchievement} format="percent" status={profitAchievement >= 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" />
<MetricCard title="目标收入" value={totalTargetRevenue} format="currency" description="各区域目标合计" />
<MetricCard title="目标利润" value={totalTargetProfit} format="currency" description="各区域利润目标合计" />
</div>
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={chartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" domain={[80, 120]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name.includes('达成率') ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="收入目标" fill="#eab308" />
<Bar yAxisId="left" dataKey="收入实际" fill="#22c55e" />
<Bar yAxisId="left" dataKey="利润目标" fill="#f97316" />
<Bar yAxisId="left" dataKey="利润实际" fill="#3b82f6" />
<Line yAxisId="right" type="monotone" dataKey="收入达成率" stroke="#ef4444" strokeWidth={2} dot={{ r: 4 }} />
<Line yAxisId="right" type="monotone" dataKey="利润达成率" stroke="#a855f7" strokeWidth={2} dot={{ r: 4 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
)
}
// HQ / Store 单实体模式
const kpi = d || {}
const revAchievement = Number(kpi.revenue_achievement_pct || 0)
const profitAchievement = Number(kpi.profit_achievement_pct || 0)
const profitMargin = Number(kpi.profit_margin_pct || 0)
const chartData = [
{ name: '收入', 目标: Number(kpi.revenue_target || 0), 实际: Number(kpi.actual_revenue || 0), 达成率: revAchievement },
{ name: '利润', 目标: Number(kpi.profit_target || 0), 实际: Number(kpi.actual_profit || 0), 达成率: profitAchievement },
]
return (
<CollapsibleSection title="KPI达成率" subtitle="收入目标 · 利润目标" defaultOpen={defaultOpen}>
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="收入达成率" value={revAchievement} format="percent" status={revAchievement >= 100 ? 'good' : revAchievement >= 80 ? 'warn' : 'bad'} description="实际收入 / 目标收入" />
<MetricCard title="利润达成率" value={profitAchievement} format="percent" status={profitAchievement >= 100 ? 'good' : profitAchievement >= 80 ? 'warn' : 'bad'} description="实际利润 / 目标利润" />
<MetricCard title="目标收入" value={kpi.revenue_target} format="currency" description="月度收入目标" />
<MetricCard title="利润率" value={profitMargin} format="percent" status={profitMargin >= 10 ? 'good' : profitMargin >= 5 ? 'warn' : 'bad'} description="实际利润 / 实际收入" />
</div>
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={chartData} 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" domain={[0, 120]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '达成率' ? `${Number(v).toFixed(1)}%` : formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="目标" fill="#eab308" />
<Bar yAxisId="left" dataKey="实际" fill="#22c55e" />
<Line yAxisId="right" type="monotone" dataKey="达成率" stroke="#ef4444" strokeWidth={2} dot={{ r: 5 }} />
</ComposedChart>
</ResponsiveContainer>
</CollapsibleSection>
)
}
+5
View File
@@ -35,6 +35,7 @@ const menuGroups: MenuGroup[] = [
title: '角色工作台',
items: [
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept'] },
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
@@ -46,6 +47,7 @@ const menuGroups: MenuGroup[] = [
{ path: '/revenue', label: '营收分析', icon: BarChart3, roles: ['hq', 'dept'] },
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
{ path: '/member-ltv', label: '会员LTV', icon: Users, roles: ['hq', 'dept'] },
],
},
{
@@ -53,6 +55,7 @@ const menuGroups: MenuGroup[] = [
items: [
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
{ path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] },
{ path: '/inventory-turnover', label: '库存周转', icon: Utensils, roles: ['hq', 'dept'] },
{ path: '/central-kitchen', label: '中央厨房', icon: ChefHat, roles: ['hq', 'dept'] },
{ path: '/distribution-reconciliation', label: '配送对账', icon: Truck, roles: ['hq', 'dept'] },
{ path: '/bom-penetration', label: 'BOM穿透', icon: Network, roles: ['hq', 'dept'] },
@@ -64,8 +67,10 @@ const menuGroups: MenuGroup[] = [
title: '运营分析',
items: [
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
],
},
{
+4
View File
@@ -9,6 +9,7 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { KPISection } from '@/components/KPISection'
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
@@ -224,6 +225,9 @@ export function BossPage() {
<MetricCard title="客单价" value={ex?.avg_bill_value} format="currency" trend={trend((last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
</div>
{/* ①b KPI达成率 */}
<KPISection month={month} level="hq" />
{/* ② 利润瀑布 */}
{wf && (
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
@@ -0,0 +1,205 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#a855f7', '#64748b', '#ec4899', '#14b8a6', '#f97316', '#6366f1']
export function EmployeePerformancePage() {
const [positionFilter, setPositionFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/employee/performance', positionFilter, statusFilter, page],
queryFn: () => api.get('/analytics-enhanced/employee/performance', {
params: { position: positionFilter, status: statusFilter, page, page_size: 50 },
}),
})
if (isLoading) {
return <LoadingSpinner text="加载员工绩效数据..." />
}
const positionSummary = (data as any)?.data?.position_summary || []
const employees = (data as any)?.data?.employees || []
const meta = (data as any)?.meta || {}
const totalHeadcount = positionSummary.reduce((s: number, r: any) => s + Number(r.headcount || 0), 0)
const totalActive = positionSummary.reduce((s: number, r: any) => s + Number(r.active_count || 0), 0)
const avgPerf = positionSummary.length > 0
? positionSummary.reduce((s: number, r: any) => s + Number(r.avg_perf_score || 0), 0) / positionSummary.length
: 0
const avgSalary = positionSummary.length > 0
? positionSummary.reduce((s: number, r: any) => s + Number(r.avg_gross_pay || 0), 0) / positionSummary.length
: 0
const positionChartData = positionSummary.slice(0, 10).map((r: any) => ({
name: r.position,
人数: Number(r.headcount || 0),
在职: Number(r.active_count || 0),
}))
const positionPieData = positionSummary.slice(0, 8).map((r: any) => ({
name: r.position,
value: Number(r.headcount || 0),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
</div>
{/* 概览指标 */}
<CollapsibleSection title="员工总览" subtitle="全员汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总人数" value={totalHeadcount} format="number" description="全部员工(含离职)" />
<MetricCard title="在职人数" value={totalActive} format="number" description="当前在职员工" status="good" />
<MetricCard title="平均绩效分" value={avgPerf} format="number" description="全员平均绩效评分" />
<MetricCard title="平均月薪" value={avgSalary} format="currency" description="全员平均应发工资" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="岗位人数分布" subtitle="TOP10岗位人数与在职数">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={positionChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Legend />
<Bar dataKey="人数" fill="#3b82f6" />
<Bar dataKey="在职" fill="#22c55e" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="岗位占比" subtitle="各岗位人数占比">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie data={positionPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{positionPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '人数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 岗位汇总 */}
<CollapsibleSection title="岗位绩效汇总" subtitle="各岗位人数、薪资、绩效、出勤率">
<FilterableTable
data={positionSummary}
filterKey="position"
filterLabel="全部岗位"
sortOptions={[
{ key: 'headcount', label: '人数' },
{ key: 'avg_gross_pay', label: '平均工资' },
{ key: 'avg_perf_score', label: '绩效分' },
{ key: 'avg_attendance_rate', label: '出勤率' },
]}
defaultSort="headcount"
defaultOrder="desc"
columns={[
{ key: 'position', label: '岗位' },
{ key: 'headcount', label: '总人数', align: 'right', render: (r) => formatNumber(r.headcount) },
{ key: 'active_count', label: '在职', align: 'right', render: (r) => formatNumber(r.active_count) },
{ key: 'avg_gross_pay', label: '平均应发', align: 'right', render: (r) => formatCurrency(r.avg_gross_pay) },
{ key: 'avg_perf_score', label: '绩效分', align: 'right', render: (r) => Number(r.avg_perf_score || 0).toFixed(2) },
{ key: 'avg_attendance_rate', label: '出勤率', align: 'right', render: (r) => {
const v = Number(r.avg_attendance_rate || 0)
return <span className={v >= 95 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
}},
]}
/>
</CollapsibleSection>
{/* 筛选器 */}
<div className="flex items-center gap-3">
<select
value={positionFilter}
onChange={(e) => { setPositionFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
{positionSummary.map((r: any) => (
<option key={r.position} value={r.position}>{r.position}</option>
))}
</select>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="在职"></option>
<option value="离职"></option>
</select>
<span className="text-xs text-muted-foreground"> {meta.total || 0} </span>
</div>
{/* 员工明细 */}
<CollapsibleSection title="员工明细" subtitle="按应发工资降序">
<FilterableTable
data={employees}
filterKey="employee_id"
filterLabel="全部员工"
sortOptions={[
{ key: 'gross_pay', label: '应发工资' },
{ key: 'perf_score', label: '绩效分' },
{ key: 'actual_attend', label: '出勤天数' },
]}
defaultSort="gross_pay"
defaultOrder="desc"
columns={[
{ key: 'employee_id', label: '工号' },
{ key: 'position', label: '岗位' },
{ key: 'status', label: '状态', render: (r) => {
const color = r.status === '在职' ? 'text-green-600' : 'text-red-600'
return <span className={color}>{r.status}</span>
}},
{ key: 'org_level2', label: '所属机构' },
{ key: 'org_level3', label: '部门/区域' },
{ key: 'gross_pay', label: '应发工资', align: 'right', render: (r) => formatCurrency(r.gross_pay) },
{ key: 'net_pay', label: '实发工资', align: 'right', render: (r) => formatCurrency(r.net_pay) },
{ key: 'perf_score', label: '绩效分', align: 'right', render: (r) => Number(r.perf_score || 0).toFixed(2) },
{ key: 'actual_attend', label: '出勤', align: 'right', render: (r) => `${r.actual_attend || 0}/${r.expected_attend || 0}` },
{ key: 'overtime_pay', label: '加班费', align: 'right', render: (r) => formatCurrency(r.overtime_pay) },
{ key: 'bonus', label: '奖金', align: 'right', render: (r) => formatCurrency(r.bonus) },
{ key: 'hire_date', label: '入职日期', render: (r) => r.hire_date ? String(r.hire_date).slice(0, 10) : '' },
]}
/>
</CollapsibleSection>
{/* 分页 */}
{meta.total > 50 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
<span className="text-sm text-muted-foreground"> {page} / {Math.ceil(meta.total / 50)} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(meta.total / 50)}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
</div>
)}
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
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 } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
export function InventoryTurnoverPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/turnover', month],
queryFn: () => api.get('/analytics-enhanced/inventory/turnover', { params: { month } }),
})
if (isLoading) {
return <LoadingSpinner text="加载库存周转数据..." />
}
const turnover = (data as any)?.data?.turnover || []
const wasteTop = (data as any)?.data?.waste_top || []
const totalOpening = turnover.reduce((s: number, r: any) => s + Number(r.opening_value || 0), 0)
const totalConsumption = turnover.reduce((s: number, r: any) => s + Number(r.consumption_value || 0), 0)
const totalWaste = turnover.reduce((s: number, r: any) => s + Number(r.waste_value || 0), 0)
const totalEnding = turnover.reduce((s: number, r: any) => s + Number(r.ending_value || 0), 0)
const avgTurnoverDays = turnover.length > 0
? turnover.reduce((s: number, r: any) => s + Number(r.turnover_days || 0), 0) / turnover.length
: 0
const turnoverChartData = turnover.slice(0, 15).map((r: any) => ({
name: r.store_name,
周转天数: Number(r.turnover_days || 0),
期末库存: Number(r.ending_value || 0),
}))
const wasteChartData = wasteTop.slice(0, 10).map((r: any) => ({
name: r.material_name,
损耗金额: Number(r.waste_value || 0),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · TOP · · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="库存总览" subtitle="全门店库存汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="期初库存" value={totalOpening} format="currency" description="月初库存总值" />
<MetricCard title="消耗金额" value={totalConsumption} format="currency" description="当月消耗总值" />
<MetricCard title="期末库存" value={totalEnding} format="currency" description="月末库存总值" />
<MetricCard title="平均周转天数" value={avgTurnoverDays} format="number" description="库存平均周转天数" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="损耗金额" value={totalWaste} format="currency" description="当月损耗总金额" status="bad" />
<MetricCard title="损耗率" value={totalConsumption > 0 ? totalWaste / totalConsumption * 100 : 0} format="percent" description="损耗金额/消耗金额" status="bad" />
<MetricCard title="门店数" value={turnover.length} format="number" description="有库存数据的门店数" />
<MetricCard title="损耗品类数" value={wasteTop.length} format="number" description="有损耗记录的品类数" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="门店周转天数" subtitle="TOP15门店库存周转天数与期末库存">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={turnoverChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
<YAxis yAxisId="left" tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '期末库存' ? formatCurrency(v) : `${v}`} />
<Legend />
<Bar yAxisId="left" dataKey="周转天数" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="期末库存" fill="#eab308" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="损耗金额TOP10" subtitle="损耗金额最高的品类">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={wasteChartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 10 }} width={100} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Legend />
<Bar dataKey="损耗金额" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 门店周转明细 */}
<CollapsibleSection title="门店库存周转明细" subtitle="按周转天数升序(天数越少越好)">
<FilterableTable
data={turnover}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'turnover_days', label: '周转天数' },
{ key: 'ending_value', label: '期末库存' },
{ key: 'consumption_value', label: '消耗金额' },
{ key: 'waste_value', label: '损耗金额' },
]}
defaultSort="turnover_days"
defaultOrder="asc"
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'opening_value', label: '期初库存', align: 'right', render: (r) => formatCurrency(r.opening_value) },
{ key: 'purchase_value', label: '采购金额', align: 'right', render: (r) => formatCurrency(r.purchase_value) },
{ key: 'consumption_value', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_value) },
{ key: 'ending_value', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_value) },
{ key: 'waste_value', label: '损耗金额', align: 'right', render: (r) => {
const v = Number(r.waste_value || 0)
return <span className={v > 1000 ? 'text-red-600 font-medium' : ''}>{formatCurrency(v)}</span>
}},
{ key: 'turnover_days', label: '周转天数', align: 'right', render: (r) => {
const v = Number(r.turnover_days || 0)
return <span className={v <= 7 ? 'text-green-600' : v <= 14 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
}},
]}
/>
</CollapsibleSection>
{/* 损耗TOP明细 */}
<CollapsibleSection title="损耗品类TOP20" subtitle="按损耗金额降序">
<FilterableTable
data={wasteTop}
filterKey="material_name"
filterLabel="全部品类"
sortOptions={[
{ key: 'waste_value', label: '损耗金额' },
{ key: 'waste_qty', label: '损耗数量' },
{ key: 'affected_stores', label: '影响门店数' },
]}
defaultSort="waste_value"
defaultOrder="desc"
columns={[
{ key: 'material_name', label: '物料名称' },
{ key: 'waste_qty', label: '损耗数量', align: 'right', render: (r) => formatNumber(r.waste_qty) },
{ key: 'waste_value', label: '损耗金额', align: 'right', render: (r) => formatCurrency(r.waste_value) },
{ key: 'affected_stores', label: '影响门店数', align: 'right', render: (r) => formatNumber(r.affected_stores) },
]}
/>
</CollapsibleSection>
</div>
)
}
+179
View File
@@ -0,0 +1,179 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#ef4444', '#a855f7', '#64748b']
export function MemberLTVPage() {
const [statusFilter, setStatusFilter] = useState('')
const [levelFilter, setLevelFilter] = useState('')
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/member/ltv', statusFilter, levelFilter, page],
queryFn: () => api.get('/analytics-enhanced/member/ltv', {
params: { status: statusFilter, level: levelFilter, page, page_size: 50 },
}),
})
if (isLoading) {
return <LoadingSpinner text="加载会员LTV数据..." />
}
const summary = (data as any)?.data?.summary || {}
const levelDist = (data as any)?.data?.level_distribution || []
const members = (data as any)?.data?.members || []
const meta = (data as any)?.meta || {}
const levelChartData = levelDist.map((r: any) => ({
name: r.member_level,
人数: Number(r.count),
平均消费: Number(r.avg_revenue),
}))
const levelPieData = levelDist.map((r: any) => ({
name: r.member_level,
value: Number(r.count),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">LTV与分层运营</h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · </p>
</div>
</div>
{/* 概览指标 */}
<CollapsibleSection title="会员总览" subtitle="全量会员LTV与活跃度">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总会员数" value={Number(summary.total_members || 0)} format="number" description="已识别的独立会员总数" />
<MetricCard title="平均LTV" value={Number(summary.avg_ltv || 0)} format="currency" description="会员生命周期平均消费金额" />
<MetricCard title="平均消费次数" value={Number(summary.avg_orders || 0)} format="number" description="会员平均到店消费次数" />
<MetricCard title="会员总收入" value={Number(summary.total_revenue || 0)} format="currency" description="全部会员累计消费金额" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活跃会员" value={Number(summary.active_count || 0)} format="number" description="30天内有消费" status="good" />
<MetricCard title="沉睡会员" value={Number(summary.dormant_count || 0)} format="number" description="30-90天未消费" status="warn" />
<MetricCard title="流失会员" value={Number(summary.churned_count || 0)} format="number" description="90天以上未消费" status="bad" />
<MetricCard title="活跃率" value={Number(summary.active_count || 0) / Number(summary.total_members || 1) * 100} format="percent" description="活跃会员占总会员比例" />
</div>
</CollapsibleSection>
{/* 等级分布图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="会员等级分布" subtitle="各等级人数与平均消费">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={levelChartData} 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" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => name === '平均消费' ? formatCurrency(v) : formatNumber(v)} />
<Legend />
<Bar yAxisId="left" dataKey="人数" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="平均消费" fill="#eab308" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="等级占比" subtitle="各等级会员占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={levelPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{levelPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '人数']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 筛选器 */}
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="活跃"></option>
<option value="沉睡"></option>
<option value="流失"></option>
</select>
<select
value={levelFilter}
onChange={(e) => { setLevelFilter(e.target.value); setPage(1) }}
className="rounded-md border px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="钻石"></option>
<option value="金卡"></option>
<option value="银卡"></option>
<option value="普通"></option>
</select>
<span className="text-xs text-muted-foreground"> {meta.total || 0} </span>
</div>
{/* 会员明细 */}
<CollapsibleSection title="会员明细" subtitle="按总消费降序">
<FilterableTable
data={members}
filterKey="member_id"
filterLabel="全部分员"
sortOptions={[
{ key: 'total_revenue', label: '总消费' },
{ key: 'total_orders', label: '消费次数' },
{ key: 'register_date', label: '注册日期' },
{ key: 'last_order_date', label: '最近消费' },
]}
defaultSort="total_revenue"
defaultOrder="desc"
columns={[
{ key: 'member_id', label: '会员ID' },
{ key: 'member_level', label: '等级', render: (r) => {
const color = r.member_level === '钻石' ? 'text-purple-600' : r.member_level === '金卡' ? 'text-yellow-600' : r.member_level === '银卡' ? 'text-blue-600' : 'text-gray-500'
return <span className={color}>{r.member_level}</span>
}},
{ key: 'status', label: '状态', render: (r) => {
const color = r.status === '活跃' ? 'text-green-600' : r.status === '沉睡' ? 'text-yellow-600' : 'text-red-600'
return <span className={color}>{r.status}</span>
}},
{ key: 'total_orders', label: '消费次数', align: 'right', render: (r) => formatNumber(r.total_orders) },
{ key: 'total_revenue', label: '总消费', align: 'right', render: (r) => formatCurrency(r.total_revenue) },
{ key: 'register_date', label: '注册日期', render: (r) => r.register_date ? String(r.register_date).slice(0, 10) : '' },
{ key: 'last_order_date', label: '最近消费', render: (r) => r.last_order_date ? String(r.last_order_date).slice(0, 10) : '' },
{ key: 'register_store_name', label: '注册门店' },
{ key: 'register_channel', label: '注册渠道' },
]}
/>
</CollapsibleSection>
{/* 分页 */}
{meta.total > 50 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
<span className="text-sm text-muted-foreground"> {page} / {Math.ceil(meta.total / 50)} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= Math.ceil(meta.total / 50)}
className="rounded-md border px-3 py-1 text-sm disabled:opacity-50"
></button>
</div>
)}
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
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 } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#ef4444']
export function MenuEngineeringPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/menu-engineering/actions', month],
queryFn: () => api.get('/analytics-enhanced/menu-engineering/actions', { params: { month } }),
})
if (isLoading) {
return <LoadingSpinner text="加载菜单工程数据..." />
}
const summary = (data as any)?.data?.summary || {}
const actions = (data as any)?.data?.actions || []
const actionCounts = [
{ name: '保留并推广', value: actions.filter((r: any) => r.action === '保留并推广').length },
{ name: '优化提升', value: actions.filter((r: any) => r.action === '优化提升').length },
{ name: '降本或提价', value: actions.filter((r: any) => r.action === '降本或提价').length },
{ name: '考虑淘汰', value: actions.filter((r: any) => r.action === '考虑淘汰').length },
]
const abcPieData = [
{ name: 'A类(核心)', value: summary.class_a || 0 },
{ name: 'B类(成长)', value: summary.class_b || 0 },
{ name: 'C类(长尾)', value: summary.class_c || 0 },
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground">ABC分类 · · ///</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="SKU概览" subtitle="ABC分类分布与行动汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总SKU数" value={Number(summary.total_skus || 0)} format="number" description="当月有销售的菜品总数" />
<MetricCard title="A类(高收入)" value={Number(summary.class_a || 0)} format="number" description="收入贡献TOP20%" status="good" />
<MetricCard title="C类(低收入)" value={Number(summary.class_c || 0)} format="number" description="收入贡献较低的菜品" status="bad" />
<MetricCard title="建议淘汰" value={Number(summary.recommend_eliminate || 0)} format="number" description="月收入<¥1000的C类SKU" status="bad" />
</div>
</CollapsibleSection>
{/* 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="ABC分类分布" subtitle="A/B/C类SKU数量占比">
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={abcPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
{abcPieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip formatter={(v: any) => [formatNumber(v), '数量']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title="行动建议分布" subtitle="各类行动建议的SKU数量">
<ResponsiveContainer width="100%" height={250}>
<BarChart data={actionCounts} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any) => [formatNumber(v), 'SKU数']} />
<Legend />
<Bar dataKey="value" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 行动清单 */}
<CollapsibleSection title="行动清单明细" subtitle="按ABC分类和收入排序">
<FilterableTable
data={actions}
filterKey="dish_name"
filterLabel="全部菜品"
sortOptions={[
{ key: 'revenue', label: '收入' },
{ key: 'order_count', label: '订单数' },
{ key: 'avg_price', label: '均价' },
{ key: 'abc_class', label: 'ABC分类' },
]}
defaultSort="revenue"
defaultOrder="desc"
columns={[
{ key: 'dish_name', label: '菜品名称' },
{ key: 'abc_class', label: 'ABC', render: (r) => {
const color = r.abc_class?.startsWith('A') ? 'text-green-600 font-medium' : r.abc_class?.startsWith('B') ? 'text-blue-600' : 'text-yellow-600'
return <span className={color}>{r.abc_class}</span>
}},
{ key: 'category_l1', label: '一级分类' },
{ key: 'category_l2', label: '二级分类' },
{ key: 'revenue', label: '月收入', align: 'right', render: (r) => formatCurrency(r.revenue) },
{ key: 'order_count', label: '订单数', align: 'right', render: (r) => formatNumber(r.order_count) },
{ key: 'avg_price', label: '均价', align: 'right', render: (r) => formatCurrency(r.avg_price) },
{ key: 'action', label: '建议行动', render: (r) => {
const color = r.action === '保留并推广' ? 'text-green-600' : r.action === '考虑淘汰' ? 'text-red-600' : r.action === '优化提升' ? 'text-blue-600' : 'text-yellow-600'
return <span className={color}>{r.action}</span>
}},
{ key: 'suggestion', label: '详细建议' },
]}
/>
</CollapsibleSection>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
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 { KPISection } from '@/components/KPISection'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
export function RegionComparisonPage() {
const [month, setMonth] = useState('2026-04')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/region/comparison', month],
queryFn: () => api.get('/analytics-enhanced/region/comparison', { params: { month } }),
})
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ['analytics-enhanced/region/kpi-forecast', month],
queryFn: () => api.get('/analytics-enhanced/region/kpi-forecast', { params: { month } }),
})
if (isLoading || forecastLoading) {
return <LoadingSpinner text="加载区域对比数据..." />
}
const rows = (data as any)?.data || []
const forecastRows = (forecastData as any)?.data || []
const totalRevenue = rows.reduce((s: number, r: any) => s + Number(r.total_revenue || 0), 0)
const totalStores = rows.reduce((s: number, r: any) => s + Number(r.store_count || 0), 0)
const avgMargin = rows.length > 0 ? rows.reduce((s: number, r: any) => s + Number(r.avg_margin_pct || 0), 0) / rows.length : 0
const chartData = rows.map((r: any) => ({
name: r.region,
总收入: Number(r.total_revenue || 0),
店均收入: Number(r.revenue_per_store || 0),
客单价: Number(r.avg_bill_value || 0),
}))
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · KPI达成预测 · {month}</p>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
{/* 概览指标 */}
<CollapsibleSection title="区域总览" subtitle="全区域汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="区域总数" value={rows.length} format="number" description="有数据的区域数量" />
<MetricCard title="区域总收入" value={totalRevenue} format="currency" description="所有区域当月总收入" />
<MetricCard title="门店总数" value={totalStores} format="number" description="所有区域门店数" />
<MetricCard title="平均毛利率" value={avgMargin} format="percent" description="各区域平均理论毛利率" />
</div>
</CollapsibleSection>
{/* KPI达成率 */}
<KPISection month={month} level="region" />
{/* 区域收入对比图 */}
<div className="grid gap-4 lg:grid-cols-2">
<CollapsibleSection title="区域收入对比" subtitle="各区域总收入与店均收入">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} tick={{ fontSize: 10 }} />
<Tooltip formatter={(v: any, name: any) => formatCurrency(v)} />
<Legend />
<Bar yAxisId="left" dataKey="总收入" fill="#3b82f6" />
<Bar yAxisId="right" dataKey="店均收入" fill="#22c55e" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
</div>
{/* 区域对比明细 */}
<CollapsibleSection title="区域对比明细" subtitle="按总收入降序">
<FilterableTable
data={rows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'total_revenue', label: '总收入' },
{ key: 'revenue_per_store', label: '店均收入' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'avg_margin_pct', label: '毛利率' },
{ key: 'member_penetration_pct', label: '会员渗透率' },
{ key: 'avg_expense_rate', label: '费用率' },
]}
defaultSort="total_revenue"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'right', render: (r) => formatNumber(r.store_count) },
{ key: 'total_revenue', label: '总收入', align: 'right', render: (r) => formatCurrency(r.total_revenue) },
{ key: 'revenue_per_store', label: '店均收入', align: 'right', render: (r) => formatCurrency(r.revenue_per_store) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'avg_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.avg_margin_pct) },
{ key: 'member_count', label: '会员数', align: 'right', render: (r) => formatNumber(r.member_count) },
{ key: 'member_penetration_pct', label: '会员渗透率', align: 'right', render: (r) => formatPercent(r.member_penetration_pct) },
{ key: 'avg_expense_rate', label: '费用率', align: 'right', render: (r) => formatPercent(r.avg_expense_rate) },
{ key: 'avg_rent_rate', label: '租金率', align: 'right', render: (r) => formatPercent(r.avg_rent_rate) },
{ key: 'avg_wage_rate', label: '工资率', align: 'right', render: (r) => formatPercent(r.avg_wage_rate) },
]}
/>
</CollapsibleSection>
{/* KPI达成预测明细 */}
<CollapsibleSection title="KPI达成预测明细" subtitle="月度目标 vs 实际完成">
<FilterableTable
data={forecastRows}
filterKey="region"
filterLabel="全部区域"
sortOptions={[
{ key: 'actual_revenue', label: '已完成' },
{ key: 'target_revenue', label: '目标' },
{ key: 'achievement_pct', label: '达成率' },
]}
defaultSort="actual_revenue"
defaultOrder="desc"
columns={[
{ key: 'region', label: '区域' },
{ key: 'target_revenue', label: '月度目标', align: 'right', render: (r) => formatCurrency(r.target_revenue) },
{ key: 'actual_revenue', label: '已完成收入', align: 'right', render: (r) => formatCurrency(r.actual_revenue) },
{ key: 'actual_bills', label: '已完成账单', align: 'right', render: (r) => formatNumber(r.actual_bills) },
{ key: 'avg_daily_revenue', label: '日均收入', align: 'right', render: (r) => formatCurrency(r.avg_daily_revenue) },
{ key: 'forecast_revenue', label: '外推预测', align: 'right', render: (r) => formatCurrency(r.forecast_revenue) },
{ key: 'achievement_pct', label: '收入达成率', align: 'right', render: (r) => {
const v = Number(r.achievement_pct || 0)
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
{ key: 'bill_achievement_pct', label: '账单达成率', align: 'right', render: (r) => {
const v = Number(r.bill_achievement_pct || 0)
return <span className={v >= 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}%</span>
}},
]}
/>
</CollapsibleSection>
</div>
)
}
+4
View File
@@ -8,6 +8,7 @@ import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
import { KPISection } from '@/components/KPISection'
import { useState } from 'react'
const SCORE_DIMENSIONS = [
@@ -262,6 +263,9 @@ export function StorePage() {
</div>
</div>
{/* KPI达成率 */}
<KPISection month={month} level="store" storeCode={storeCode} />
{/* 最新经营概览 + 本周指标进度 一行 */}
<div className="grid gap-4 lg:grid-cols-2">
{/* 经营概览 */}