feat: 门店费用分析模块 + 成本分析筛选排序功能
- 新增门店费用分析模块(9个Tab组件 + 后端路由) - 门店贡献利润、房租租约风险、外卖佣金分析、人效坪效 - 固定/变动费用、盈亏平衡分析、亏损门店诊断 - 关停/续租/改造评估、门店排名 - 门店费用分析9个Tab均支持筛选、排序、正倒序 - 成本分析8个Tab添加筛选、排序、正倒序功能 - 后端10个端点添加filter/sort/order参数支持 - 前端8个组件添加UI控件 - 修复门店评估分类逻辑和日期格式化 - 修复盈亏平衡销售额计算(贡献毛利率≤0时设为NULL)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const SAFETY_COLORS: Record<string, string> = {
|
||||
'无法盈利': 'text-red-700 font-bold',
|
||||
'未达盈亏平衡': 'text-red-600 font-medium',
|
||||
'接近盈亏平衡': 'text-orange-600 font-medium',
|
||||
'安全边际较低': 'text-yellow-600',
|
||||
'安全边际充足': 'text-green-600',
|
||||
}
|
||||
|
||||
export function BreakEvenTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('safety_margin_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/break-even', page, sort, filter, order], queryFn: () => api.get(`/store-expense/break-even?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载盈亏平衡分析..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'safety_margin_pct', label: '安全边际率' },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡额' },
|
||||
{ key: 'sales_gap', label: '销售缺口' },
|
||||
{ key: 'food_cost_rate_pct', label: '食材成本率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="盈亏平衡分析" subtitle="盈亏平衡销售额 = 固定费用 ÷ (1 - 食材成本率),安全边际 = (实收 - 平衡点) ÷ 平衡点">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'actual_food_cost', label: '食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||||
{ key: 'food_cost_rate_pct', label: '食材成本率', align: 'right', render: (r) => formatPercent(r.food_cost_rate_pct) },
|
||||
{ key: 'operating_expense', label: '营业费用', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'expense_rate_pct', label: '费用率', align: 'right', render: (r) => formatPercent(r.expense_rate_pct) },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡销售额', align: 'right', render: (r) => formatCurrency(r.break_even_sales) },
|
||||
{ key: 'sales_gap', label: '销售缺口', align: 'right', render: (r) => <span className={parseFloat(r.sales_gap) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.sales_gap)}</span> },
|
||||
{ key: 'safety_margin_pct', label: '安全边际率', align: 'right', render: (r) => <span className={parseFloat(r.safety_margin_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.safety_margin_pct)}</span> },
|
||||
{ key: 'safety_status', label: '安全状态', render: (r) => <span className={SAFETY_COLORS[r.safety_status] || ''}>{r.safety_status}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function ContributionTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/contribution', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-contribution?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店贡献利润..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'contribution_variance', label: '贡献差异' },
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店贡献利润分析" subtitle="实收 - 食材成本 - 营业费用 = 门店贡献利润">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'theoretical_cost', label: '理论食材成本', align: 'right', render: (r) => formatCurrency(r.theoretical_cost) },
|
||||
{ key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) },
|
||||
{ key: 'operating_expense', label: '营业费用', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'theoretical_store_contribution', label: '理论贡献', align: 'right', render: (r) => formatCurrency(r.theoretical_store_contribution) },
|
||||
{ key: 'theoretical_store_contribution_rate_pct', label: '理论贡献率', align: 'right', render: (r) => formatPercent(r.theoretical_store_contribution_rate_pct) },
|
||||
{ key: 'actual_store_contribution', label: '实际贡献', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.actual_store_contribution)}</span> },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '实际贡献率', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution_rate_pct) < 0 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
|
||||
{ key: 'contribution_variance', label: '贡献差异', align: 'right', render: (r) => <span className={parseFloat(r.contribution_variance) < 0 ? 'text-red-600' : 'text-green-600'}>{formatCurrency(r.contribution_variance)}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function DeliveryTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('commission_to_delivery_sales_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/delivery', page, sort, filter, order], queryFn: () => api.get(`/store-expense/delivery-commission?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载外卖佣金分析..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'commission_to_delivery_sales_pct', label: '佣金率' },
|
||||
{ key: 'delivery_sales_share_pct', label: '外卖占比' },
|
||||
{ key: 'delivery_received', label: '外卖收入' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'combined_platform_cost_rate_pct', label: '综合成本率' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="外卖佣金分析" subtitle="外卖收入占比、佣金率及综合平台成本率">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'delivery_received', label: '外卖收入', align: 'right', render: (r) => formatCurrency(r.delivery_received) },
|
||||
{ key: 'delivery_sales_share_pct', label: '外卖占比', align: 'right', render: (r) => <span className={parseFloat(r.delivery_sales_share_pct) > 40 ? 'text-red-600 font-medium' : parseFloat(r.delivery_sales_share_pct) > 25 ? 'text-orange-600' : ''}>{formatPercent(r.delivery_sales_share_pct)}</span> },
|
||||
{ key: 'delivery_commission_expense', label: '佣金支出', align: 'right', render: (r) => formatCurrency(r.delivery_commission_expense) },
|
||||
{ key: 'commission_to_delivery_sales_pct', label: '佣金率', align: 'right', render: (r) => <span className={parseFloat(r.commission_to_delivery_sales_pct) > 25 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.commission_to_delivery_sales_pct)}</span> },
|
||||
{ key: 'combined_platform_cost_rate_pct', label: '综合平台成本率', align: 'right', render: (r) => formatPercent(r.combined_platform_cost_rate_pct) },
|
||||
{ key: 'delivery_type', label: '外卖类型' },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function EfficiencyTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('received_per_sqm')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/efficiency', page, sort, filter, order], queryFn: () => api.get(`/store-expense/efficiency?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载人效坪效数据..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'bill_count', label: '账单数' },
|
||||
{ key: 'avg_ticket_size', label: '客单价' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="人效坪效分析" subtitle="坪效(元/㎡) + 人工成本率 + 客单价 + 营业效率评估">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => formatNumber(r.area_sqm) },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => <span className={parseFloat(r.received_per_sqm) < 1000 ? 'text-red-600 font-medium' : parseFloat(r.received_per_sqm) > 4000 ? 'text-green-600 font-medium' : ''}>{formatNumber(r.received_per_sqm)}</span> },
|
||||
{ key: 'wage_expense', label: '人工成本', align: 'right', render: (r) => formatCurrency(r.wage_expense) },
|
||||
{ key: 'wage_rate_pct', label: '人工成本率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 35 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'revenue_per_wage', label: '人效(元/元人工)', align: 'right', render: (r) => formatNumber(r.revenue_per_wage) },
|
||||
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
|
||||
{ key: 'avg_ticket_size', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_ticket_size) },
|
||||
{ key: 'efficiency_level', label: '效率等级', render: (r) => <span className={r.efficiency_level === '坪效偏低' || r.efficiency_level === '坪效极低' ? 'text-red-600' : r.efficiency_level === '坪效优秀' ? 'text-green-600' : ''}>{r.efficiency_level}</span> },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'
|
||||
|
||||
const PIE_COLORS = ['#3b82f6', '#ef4444', '#22c55e', '#f97316', '#eab308', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#a3a3a3', '#f43f5e', '#6366f1', '#14b8a6', '#facc15', '#7c3aed', '#10b981', '#fb923c']
|
||||
|
||||
export function ExpenseOverviewTab() {
|
||||
const { data: overview, isLoading: l1 } = useQuery({ queryKey: ['se/overview'], queryFn: () => api.get('/store-expense/overview') })
|
||||
const { data: structure, isLoading: l2 } = useQuery({ queryKey: ['se/structure'], queryFn: () => api.get('/store-expense/expense-structure') })
|
||||
|
||||
if (l1 || l2) return <LoadingSpinner text="加载费用总览..." />
|
||||
|
||||
const ov = (overview as any)?.data || {}
|
||||
const structureData = (structure as any)?.data || []
|
||||
|
||||
const pieData = structureData.map((r: any) => ({ name: r.account_name, value: parseFloat(r.amount) }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="经营门店" value={ov.total_stores} unit="家" description={`${ov.profitable_stores}家盈利 / ${ov.loss_stores}家亏损`} />
|
||||
<MetricCard title="实收合计" value={ov.total_received} format="currency" description={`费用合计 ${formatCurrency(ov.total_expense)}`} />
|
||||
<MetricCard title="门店贡献利润" value={ov.total_contribution} format="currency" description={`贡献率 ${formatPercent(ov.overall_contribution_rate_pct)}`} />
|
||||
<MetricCard title="费用率" value={ov.overall_expense_rate_pct} format="percent" description={`人工 ${formatPercent(ov.overall_wage_rate_pct)} · 房租 ${formatPercent(ov.overall_rent_rate_pct)}`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CollapsibleSection title="费用结构分析" subtitle="2026年4月各费用科目占比">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} label={(e: any) => e.name.length > 6 ? e.name.slice(0, 6) + '…' : e.name}>
|
||||
{pieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="费用科目明细" subtitle="按金额降序">
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
<th className="text-left p-2">费用科目</th>
|
||||
<th className="text-right p-2">金额</th>
|
||||
<th className="text-right p-2">占比</th>
|
||||
<th className="text-right p-2">门店数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{structureData.map((r: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="p-2">{r.account_name}</td>
|
||||
<td className="text-right p-2">{formatCurrency(r.amount)}</td>
|
||||
<td className="text-right p-2">{formatPercent(r.expense_share_pct)}</td>
|
||||
<td className="text-right p-2">{r.nonzero_cost_unit_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function FixedVariableTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('fixed_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/fixed-variable', page, sort, filter, order], queryFn: () => api.get(`/store-expense/fixed-variable?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载固定/变动费用拆分..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'fixed_rate_pct', label: '固定费用率' },
|
||||
{ key: 'variable_rate_pct', label: '变动费用率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'contribution_after_variable', label: '边际贡献' },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡额' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="固定/变动费用拆分" subtitle="固定费用(租金+宿舍+维修50%) vs 变动费用(人工+水电+佣金+刷卡+维修50%)">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'fixed_expense', label: '固定费用', align: 'right', render: (r) => formatCurrency(r.fixed_expense) },
|
||||
{ key: 'fixed_rate_pct', label: '固定费用率', align: 'right', render: (r) => formatPercent(r.fixed_rate_pct) },
|
||||
{ key: 'variable_expense', label: '变动费用', align: 'right', render: (r) => formatCurrency(r.variable_expense) },
|
||||
{ key: 'variable_rate_pct', label: '变动费用率', align: 'right', render: (r) => formatPercent(r.variable_rate_pct) },
|
||||
{ key: 'contribution_after_variable', label: '边际贡献(扣变动)', align: 'right', render: (r) => <span className={parseFloat(r.contribution_after_variable) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.contribution_after_variable)}</span> },
|
||||
{ key: 'break_even_sales', label: '盈亏平衡销售额', align: 'right', render: (r) => formatCurrency(r.break_even_sales) },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
'P0': 'bg-red-100 text-red-700',
|
||||
'P1': 'bg-orange-100 text-orange-700',
|
||||
'P2': 'bg-yellow-100 text-yellow-700',
|
||||
'P3': 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
const LOSS_TYPE_COLORS: Record<string, string> = {
|
||||
'理论即亏损': 'text-red-600 font-medium',
|
||||
'实际超耗导致亏损': 'text-orange-600 font-medium',
|
||||
'费用过高导致亏损': 'text-yellow-600 font-medium',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
'收入极低': 'text-red-600 font-medium',
|
||||
'收入偏低': 'text-orange-600',
|
||||
'食材成本率过高': 'text-red-600 font-medium',
|
||||
'食材成本率偏高': 'text-orange-600',
|
||||
'人工成本率严重过高': 'text-red-600 font-medium',
|
||||
'人工成本率过高': 'text-red-600',
|
||||
'人工成本率偏高': 'text-orange-600',
|
||||
'租金占比严重过高': 'text-red-600 font-medium',
|
||||
'租金占比过高': 'text-red-600',
|
||||
'租金占比偏高': 'text-orange-600',
|
||||
'水电燃气率过高': 'text-red-600',
|
||||
'水电燃气率偏高': 'text-orange-600',
|
||||
'外卖依赖度过高': 'text-red-600 font-medium',
|
||||
'外卖占比较高': 'text-orange-600',
|
||||
'坪效极低': 'text-red-600 font-medium',
|
||||
'坪效偏低': 'text-orange-600',
|
||||
'租约即将到期': 'text-red-600 font-medium',
|
||||
}
|
||||
|
||||
interface StoreDetail {
|
||||
sales_store_name: string
|
||||
received: string
|
||||
actual_food_cost: string
|
||||
operating_expense: string
|
||||
actual_store_contribution: string
|
||||
actual_store_contribution_rate_pct: string
|
||||
wage_expense: string
|
||||
wage_rate_pct: string
|
||||
rent_expense: string
|
||||
rent_rate_pct: string
|
||||
utility_expense: string
|
||||
utility_rate_pct: string
|
||||
dorm_expense: string
|
||||
delivery_commission_expense: string
|
||||
delivery_received: string
|
||||
delivery_sales_share_pct: string
|
||||
area_sqm: string
|
||||
received_per_sqm: string
|
||||
bill_count: string
|
||||
lease_expiry_date: string
|
||||
theoretical_cost: string
|
||||
theoretical_store_contribution: string
|
||||
theoretical_store_contribution_rate_pct: string
|
||||
revenue_status: string
|
||||
food_cost_status: string
|
||||
wage_status: string
|
||||
rent_status: string
|
||||
utility_status: string
|
||||
delivery_status: string
|
||||
efficiency_status: string
|
||||
lease_status: string
|
||||
loss_type: string
|
||||
diagnosis_reasons: string
|
||||
diagnosis_suggestions: string
|
||||
suggested_actions: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
function StoreDetailModal({ store, onClose }: { store: StoreDetail | null, onClose: () => void }) {
|
||||
if (!store) return null
|
||||
|
||||
const statusItems = [
|
||||
{ label: '收入状态', value: store.revenue_status },
|
||||
{ label: '食材成本', value: store.food_cost_status },
|
||||
{ label: '人工成本', value: store.wage_status },
|
||||
{ label: '租金占比', value: store.rent_status },
|
||||
{ label: '水电燃气', value: store.utility_status },
|
||||
{ label: '外卖依赖', value: store.delivery_status },
|
||||
{ label: '坪效', value: store.efficiency_status },
|
||||
{ label: '租约', value: store.lease_status },
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ label: '实收', value: formatCurrency(store.received) },
|
||||
{ label: '食材成本', value: formatCurrency(store.actual_food_cost) },
|
||||
{ label: '营业费用', value: formatCurrency(store.operating_expense) },
|
||||
{ label: '贡献利润', value: formatCurrency(store.actual_store_contribution), red: true },
|
||||
{ label: '贡献率', value: formatPercent(store.actual_store_contribution_rate_pct), red: true },
|
||||
{ label: '理论贡献', value: formatCurrency(store.theoretical_store_contribution) },
|
||||
{ label: '理论贡献率', value: formatPercent(store.theoretical_store_contribution_rate_pct) },
|
||||
{ label: '人工成本', value: formatCurrency(store.wage_expense) },
|
||||
{ label: '人工成本率', value: formatPercent(store.wage_rate_pct) },
|
||||
{ label: '租金', value: formatCurrency(store.rent_expense) },
|
||||
{ label: '租金率', value: formatPercent(store.rent_rate_pct) },
|
||||
{ label: '水电燃气', value: formatCurrency(store.utility_expense) },
|
||||
{ label: '水电率', value: formatPercent(store.utility_rate_pct) },
|
||||
{ label: '宿舍费用', value: formatCurrency(store.dorm_expense) },
|
||||
{ label: '外卖佣金', value: formatCurrency(store.delivery_commission_expense) },
|
||||
{ label: '外卖收入', value: formatCurrency(store.delivery_received) },
|
||||
{ label: '外卖占比', value: formatPercent(store.delivery_sales_share_pct) },
|
||||
{ label: '面积(㎡)', value: formatNumber(store.area_sqm) },
|
||||
{ label: '坪效', value: formatNumber(store.received_per_sqm) },
|
||||
{ label: '账单数', value: formatNumber(store.bill_count) },
|
||||
{ label: '租约到期', value: store.lease_expiry_date ? new Date(store.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold">{store.sales_store_name}</h2>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[store.priority] || ''}`}>{store.priority}</span>
|
||||
<span className={`text-sm ${LOSS_TYPE_COLORS[store.loss_type] || ''}`}>{store.loss_type}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">关键指标</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{m.label}</p>
|
||||
<p className={`text-sm font-medium ${m.red ? 'text-red-600' : ''}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">诊断状态</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{statusItems.map((s, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className={`text-sm font-medium ${STATUS_COLORS[s.value] || ''}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">亏损原因分析</h3>
|
||||
<div className="rounded border p-3 bg-red-50">
|
||||
<p className="text-sm">{store.diagnosis_reasons}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">改进建议</h3>
|
||||
<div className="rounded border p-3 bg-blue-50">
|
||||
<p className="text-sm text-blue-700">{store.diagnosis_suggestions}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">建议动作</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{store.suggested_actions.split('、').map((action, i) => (
|
||||
<span key={i} className="px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">{action}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LossDiagnosisTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const [selectedStore, setSelectedStore] = useState<StoreDetail | null>(null)
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/loss-diagnosis', page, sort, filter, order], queryFn: () => api.get(`/store-expense/loss-diagnosis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载亏损门店诊断..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const p0Count = rows.filter((r: any) => r.priority === 'P0').length
|
||||
const p1Count = rows.filter((r: any) => r.priority === 'P1').length
|
||||
const totalLoss = rows.reduce((sum: number, r: any) => sum + parseFloat(r.actual_store_contribution || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="亏损门店数" value={meta.total} unit="家" description="贡献利润 ≤ 0 的门店" />
|
||||
<MetricCard title="P0 紧急" value={p0Count} unit="家" description="贡献率 < -20%" />
|
||||
<MetricCard title="P1 整改" value={p1Count} unit="家" description="贡献率 -20% ~ -5%" />
|
||||
<MetricCard title="亏损金额合计" value={totalLoss} format="currency" description="当前页亏损门店贡献利润之和" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="亏损门店诊断" subtitle="点击门店名称查看详情">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'P0', label: 'P0 紧急' },
|
||||
{ key: 'P1', label: 'P1 整改' },
|
||||
{ key: 'P2', label: 'P2 观察' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as StoreDetail)}>{r.sales_store_name}</button> },
|
||||
{ key: 'priority', label: '优先级', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[r.priority] || ''}`}>{r.priority}</span> },
|
||||
{ key: 'loss_type', label: '亏损类型', render: (r) => <span className={LOSS_TYPE_COLORS[r.loss_type] || ''}>{r.loss_type}</span> },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className="text-red-600 font-medium">{formatCurrency(r.actual_store_contribution)}</span> },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
|
||||
{ key: 'wage_rate_pct', label: '人工率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 35 ? 'text-red-600' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => <span className={parseFloat(r.received_per_sqm) < 1000 ? 'text-red-600' : ''}>{formatNumber(r.received_per_sqm)}</span> },
|
||||
{ key: 'diagnosis_reasons', label: '亏损原因', render: (r) => <span className="text-xs">{r.diagnosis_reasons}</span> },
|
||||
{ key: 'suggested_actions', label: '建议动作', render: (r) => <span className="text-xs font-medium">{r.suggested_actions}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
'即将到期': 'text-red-600 font-medium',
|
||||
'年内到期': 'text-orange-600 font-medium',
|
||||
'明年上半年到期': 'text-yellow-600',
|
||||
'租金占比偏高': 'text-orange-600',
|
||||
'正常': '',
|
||||
}
|
||||
|
||||
export function RentRiskTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('rent_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [riskOnly, setRiskOnly] = useState(false)
|
||||
const [order, setOrder] = useState('desc')
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/rent', page, sort, filter, riskOnly, order], queryFn: () => api.get(`/store-expense/rent-risk?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&risk_only=${riskOnly}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载房租及租约风险..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'rent_expense', label: '租金' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'lease_expiry_date', label: '租约到期' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="房租及租约风险" subtitle="租金占比 + 租约到期日,识别续租和关停风险门店">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setRiskOnly(!riskOnly); setPage(1) }} className={`px-3 py-1 rounded text-xs ${riskOnly ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||
{riskOnly ? '显示全部' : '仅显示风险门店'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'rent_expense', label: '租金', align: 'right', render: (r) => formatCurrency(r.rent_expense) },
|
||||
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
||||
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => formatNumber(r.area_sqm) },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
|
||||
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? new Date(r.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
|
||||
{ key: 'risk_level', label: '风险等级', render: (r) => <span className={RISK_COLORS[r.risk_level] || ''}>{r.risk_level}</span> },
|
||||
{ key: 'suggestion', label: '建议' },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const EVAL_COLORS: Record<string, string> = {
|
||||
'关停评估': 'bg-red-100 text-red-700',
|
||||
'关停或迁址评估': 'bg-red-100 text-red-700',
|
||||
'改造评估': 'bg-orange-100 text-orange-700',
|
||||
'续租评估': 'bg-yellow-100 text-yellow-700',
|
||||
'关注观察': 'bg-blue-100 text-blue-700',
|
||||
'正常经营': 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
'P0': 'text-red-600 font-medium',
|
||||
'P1': 'text-orange-600 font-medium',
|
||||
'P2': 'text-yellow-600',
|
||||
'P3': 'text-blue-600',
|
||||
'P4': 'text-green-600',
|
||||
}
|
||||
|
||||
interface EvalStoreDetail {
|
||||
sales_store_name: string
|
||||
received: string
|
||||
actual_food_cost: string
|
||||
operating_expense: string
|
||||
actual_store_contribution: string
|
||||
actual_store_contribution_rate_pct: string
|
||||
wage_rate_pct: string
|
||||
rent_rate_pct: string
|
||||
received_per_sqm: string
|
||||
area_sqm: string
|
||||
lease_expiry_date: string
|
||||
theoretical_store_contribution: string
|
||||
evaluation_type: string
|
||||
evaluation_detail: string
|
||||
evaluation_suggestion: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
function EvalDetailModal({ store, onClose }: { store: EvalStoreDetail | null, onClose: () => void }) {
|
||||
if (!store) return null
|
||||
|
||||
const metrics = [
|
||||
{ label: '实收', value: formatCurrency(store.received) },
|
||||
{ label: '食材成本', value: formatCurrency(store.actual_food_cost) },
|
||||
{ label: '营业费用', value: formatCurrency(store.operating_expense) },
|
||||
{ label: '贡献利润', value: formatCurrency(store.actual_store_contribution), red: parseFloat(store.actual_store_contribution) < 0 },
|
||||
{ label: '贡献率', value: formatPercent(store.actual_store_contribution_rate_pct), red: parseFloat(store.actual_store_contribution_rate_pct) < 0 },
|
||||
{ label: '理论贡献', value: formatCurrency(store.theoretical_store_contribution) },
|
||||
{ label: '人工成本率', value: formatPercent(store.wage_rate_pct), warn: parseFloat(store.wage_rate_pct) > 30 },
|
||||
{ label: '租金率', value: formatPercent(store.rent_rate_pct), warn: parseFloat(store.rent_rate_pct) > 20 },
|
||||
{ label: '坪效', value: formatNumber(store.received_per_sqm), warn: parseFloat(store.received_per_sqm) < 1000 },
|
||||
{ label: '面积(㎡)', value: formatNumber(store.area_sqm) },
|
||||
{ label: '租约到期', value: store.lease_expiry_date ? new Date(store.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold">{store.sales_store_name}</h2>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${EVAL_COLORS[store.evaluation_type] || ''}`}>{store.evaluation_type}</span>
|
||||
<span className={`text-sm ${PRIORITY_COLORS[store.priority] || ''}`}>{store.priority}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">关键指标</h3>
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{metrics.map((m, i) => (
|
||||
<div key={i} className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">{m.label}</p>
|
||||
<p className={`text-sm font-medium ${m.red ? 'text-red-600' : m.warn ? 'text-orange-600' : ''}`}>{m.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">评估详情</h3>
|
||||
<div className="rounded border p-3 bg-muted/50">
|
||||
<p className="text-sm">{store.evaluation_detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">建议</h3>
|
||||
<div className="rounded border p-3 bg-blue-50">
|
||||
<p className="text-sm text-blue-700">{store.evaluation_suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoreEvaluationTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('asc')
|
||||
const [selectedStore, setSelectedStore] = useState<EvalStoreDetail | null>(null)
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/evaluation', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-evaluation?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店评估..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const evalStats = (data as any)?.meta?.evalStats || {}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricCard title="关停评估" value={parseInt(evalStats['关停评估'] || '0')} unit="家" description="收入极低或严重亏损" />
|
||||
<MetricCard title="改造评估" value={parseInt(evalStats['改造评估'] || '0')} unit="家" description="坪效低或人工率偏高" />
|
||||
<MetricCard title="续租评估" value={parseInt(evalStats['续租评估'] || '0')} unit="家" description="租约即将到期" />
|
||||
<MetricCard title="关注观察" value={parseInt(evalStats['关注观察'] || '0')} unit="家" description="亏损或盈利能力薄弱" />
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="门店关停/续租/改造评估" subtitle="点击门店名称查看详情">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{[
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
].map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{[
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
].map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as EvalStoreDetail)}>{r.sales_store_name}</button> },
|
||||
{ key: 'priority', label: '优先级', render: (r) => <span className={PRIORITY_COLORS[r.priority] || ''}>{r.priority}</span> },
|
||||
{ key: 'evaluation_type', label: '评估类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${EVAL_COLORS[r.evaluation_type] || ''}`}>{r.evaluation_type}</span> },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.actual_store_contribution)}</span> },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => formatPercent(r.actual_store_contribution_rate_pct) },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
|
||||
{ key: 'lease_expiry_date', label: '租约到期', render: (r) => r.lease_expiry_date ? new Date(r.lease_expiry_date).toLocaleDateString('zh-CN') : '-' },
|
||||
{ key: 'evaluation_detail', label: '评估详情', render: (r) => <span className="text-xs">{r.evaluation_detail}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<EvalDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function StoreRankingTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('operating_expense_rate_pct')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [order, setOrder] = useState('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['se/ranking', page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}`) })
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载门店费用率排名..." />
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'profitable', label: '盈利门店' },
|
||||
{ key: 'loss', label: '亏损门店' },
|
||||
{ key: 'zero_sales', label: '零销售门店' },
|
||||
]
|
||||
|
||||
const sorts = [
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率' },
|
||||
{ key: 'wage_rate_pct', label: '人工率' },
|
||||
{ key: 'rent_rate_pct', label: '租金率' },
|
||||
{ key: 'received', label: '实收' },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润' },
|
||||
{ key: 'received_per_sqm', label: '坪效' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店费用率排名" subtitle="按费用率降序,支持筛选和排序">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{filters.map(f => (
|
||||
<button key={f.key} onClick={() => { setFilter(f.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${filter === f.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{f.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'sales_store_name', label: '门店' },
|
||||
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
||||
{ key: 'operating_expense', label: '费用合计', align: 'right', render: (r) => formatCurrency(r.operating_expense) },
|
||||
{ key: 'operating_expense_rate_pct', label: '费用率', align: 'right', render: (r) => <span className={parseFloat(r.operating_expense_rate_pct) > 50 ? 'text-red-600 font-medium' : ''}>{formatPercent(r.operating_expense_rate_pct)}</span> },
|
||||
{ key: 'wage_rate_pct', label: '人工率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 30 ? 'text-red-600' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
||||
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
||||
{ key: 'utility_rate_pct', label: '水电率', align: 'right', render: (r) => formatPercent(r.utility_rate_pct) },
|
||||
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className={parseFloat(r.actual_store_contribution) < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(r.actual_store_contribution)}</span> },
|
||||
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => formatPercent(r.actual_store_contribution_rate_pct) },
|
||||
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => formatNumber(r.received_per_sqm) },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user