feat: 门店费用分析模块 + 成本分析筛选排序功能
- 新增门店费用分析模块(9个Tab组件 + 后端路由) - 门店贡献利润、房租租约风险、外卖佣金分析、人效坪效 - 固定/变动费用、盈亏平衡分析、亏损门店诊断 - 关停/续租/改造评估、门店排名 - 门店费用分析9个Tab均支持筛选、排序、正倒序 - 成本分析8个Tab添加筛选、排序、正倒序功能 - 后端10个端点添加filter/sort/order参数支持 - 前端8个组件添加UI控件 - 修复门店评估分类逻辑和日期格式化 - 修复盈亏平衡销售额计算(贡献毛利率≤0时设为NULL)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user