Files
SBrainCO/client/src/components/store-expense/StoreEvaluationTab.tsx
T
freedakgmail 8f5271fcc6 fix: P2/P3审计问题修复 + 更新审计文档移除已修复问题
- 修复schema前缀:确认central_kitchen/mv_distribution/mv_dish_sales等表在public schema,撤销错误的analytics.前缀
- 移除API内部物化视图刷新:mv_distribution_monthly/mv_dish_sales_monthly改为console.warn
- 修复BOM树节点key:使用path字段替代material_code+level确保唯一性
- 修复HR人才矩阵potential_score:基于v3_learning_record动态计算替代硬编码60
- 修复StoreDetailPage公司均值:优先使用API返回的companyAvg
- 修复前端字段名匹配:discount_rate_pct/check_comment等
- 添加explicit column list和LIMIT到大型查询
- 更新数据溯源审计.md:移除全部52个已修复/确认无需修复的问题条目
2026-08-11 22:00:58 +08:00

180 lines
9.2 KiB
TypeScript

import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
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 ? String(store.lease_expiry_date).substring(0, 10) : '-' },
]
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({ month }: { month: string }) {
const [page, setPage] = useState(1)
const [sort, setSort] = useState('actual_store_contribution_rate_pct')
const [filter, setFilter] = useState('')
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
const [selectedStore, setSelectedStore] = useState<EvalStoreDetail | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['se/evaluation', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/store-evaluation?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
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="点击门店名称查看详情">
<FilterableTable
data={rows}
serverSide
page={page}
onPageChange={setPage}
sort={sort}
onSortChange={setSort}
order={order}
onOrderChange={setOrder}
pageSize={PAGE_SIZE}
total={meta.total}
filter={filter}
onFilterChange={setFilter}
filterButtons={[
{ key: '', label: '全部' },
{ key: 'profitable', label: '盈利门店' },
{ key: 'loss', label: '亏损门店' },
]}
sortOptions={[
{ 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: '租金率' },
]}
defaultSort="actual_store_contribution_rate_pct"
defaultOrder="asc"
onRowClick={(r) => setSelectedStore(r as EvalStoreDetail)}
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 ? String(r.lease_expiry_date).substring(0, 10) : '-' },
{ key: 'evaluation_detail', label: '评估详情', render: (r) => <span className="text-xs">{r.evaluation_detail}</span> },
]}
/>
</CollapsibleSection>
<EvalDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
</div>
)
}