fix: 成本分析Tab改用FilterableTable带分页筛选排序,提取共享组件
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { DataTable } from '@/components/DataTable'
|
||||||
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
interface FilterableTableProps {
|
||||||
|
data: any[]
|
||||||
|
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
|
||||||
|
onRowClick?: (row: any) => void
|
||||||
|
filterKey?: string
|
||||||
|
filterLabel?: string
|
||||||
|
sortOptions: { key: string; label: string }[]
|
||||||
|
defaultSort?: string
|
||||||
|
defaultOrder?: 'asc' | 'desc'
|
||||||
|
statusFilterKey?: string
|
||||||
|
statusFilterLabel?: string
|
||||||
|
statusOptions?: { value: string; label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterableTable({
|
||||||
|
data, columns, onRowClick,
|
||||||
|
filterKey, filterLabel,
|
||||||
|
sortOptions, defaultSort, defaultOrder = 'desc',
|
||||||
|
statusFilterKey, statusFilterLabel, statusOptions,
|
||||||
|
}: FilterableTableProps) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [sort, setSort] = useState(defaultSort || sortOptions[0]?.key || '')
|
||||||
|
const [order, setOrder] = useState<'asc' | 'desc'>(defaultOrder)
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
|
||||||
|
const filterValues = useMemo(() => {
|
||||||
|
if (!filterKey) return []
|
||||||
|
return [...new Set(data.map((r: any) => r[filterKey]).filter(Boolean))].sort() as string[]
|
||||||
|
}, [data, filterKey])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let r = data
|
||||||
|
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
|
||||||
|
if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter)
|
||||||
|
return [...r].sort((a: any, b: any) => {
|
||||||
|
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
|
||||||
|
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
|
||||||
|
return order === 'desc' ? bv - av : av - bv
|
||||||
|
})
|
||||||
|
}, [data, filter, filterKey, sort, order, statusFilter, statusFilterKey])
|
||||||
|
|
||||||
|
const total = filtered.length
|
||||||
|
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{filterKey && (
|
||||||
|
<select value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||||
|
<option value="">{filterLabel || '全部'}</option>
|
||||||
|
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{statusFilterKey && statusOptions && (
|
||||||
|
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
||||||
|
<option value="">{statusFilterLabel || '全部状态'}</option>
|
||||||
|
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<span className="mx-1 text-muted-foreground">|</span>
|
||||||
|
{sortOptions.map(s => (
|
||||||
|
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||||
|
按{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="mx-1 text-muted-foreground">|</span>
|
||||||
|
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="rounded bg-muted px-3 py-1 text-xs">
|
||||||
|
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} data={paged} onRowClick={onRowClick} />
|
||||||
|
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,93 +6,16 @@ import api from '@/lib/api'
|
|||||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { FilterableTable } from '@/components/FilterableTable'
|
||||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||||
import { Badge } from '@/components/Badge'
|
import { Badge } from '@/components/Badge'
|
||||||
import { formatCurrency, cn } from '@/lib/utils'
|
import { formatCurrency, cn } from '@/lib/utils'
|
||||||
import { Activity, AlertTriangle, Link2, TrendingUp } from 'lucide-react'
|
import { Activity, AlertTriangle, Link2, TrendingUp } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// ============ 可复用分页筛选排序表格 ============
|
// ============ 可复用分页筛选排序表格 ============
|
||||||
|
|
||||||
interface FilterableTableProps {
|
|
||||||
data: any[]
|
|
||||||
columns: { key: string; label: string; align?: 'left' | 'right' | 'center'; render?: (row: any) => ReactNode }[]
|
|
||||||
onRowClick?: (row: any) => void
|
|
||||||
filterKey?: string
|
|
||||||
filterLabel?: string
|
|
||||||
sortOptions: { key: string; label: string }[]
|
|
||||||
defaultSort?: string
|
|
||||||
defaultOrder?: 'asc' | 'desc'
|
|
||||||
statusFilterKey?: string
|
|
||||||
statusFilterLabel?: string
|
|
||||||
statusOptions?: { value: string; label: string }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterableTable({
|
|
||||||
data, columns, onRowClick,
|
|
||||||
filterKey, filterLabel,
|
|
||||||
sortOptions, defaultSort, defaultOrder = 'desc',
|
|
||||||
statusFilterKey, statusFilterLabel, statusOptions,
|
|
||||||
}: FilterableTableProps) {
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [filter, setFilter] = useState('')
|
|
||||||
const [sort, setSort] = useState(defaultSort || sortOptions[0]?.key || '')
|
|
||||||
const [order, setOrder] = useState<'asc' | 'desc'>(defaultOrder)
|
|
||||||
const [statusFilter, setStatusFilter] = useState('')
|
|
||||||
|
|
||||||
const filterValues = useMemo(() => {
|
|
||||||
if (!filterKey) return []
|
|
||||||
return [...new Set(data.map((r: any) => r[filterKey]).filter(Boolean))].sort() as string[]
|
|
||||||
}, [data, filterKey])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let r = data
|
|
||||||
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
|
|
||||||
if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter)
|
|
||||||
return [...r].sort((a: any, b: any) => {
|
|
||||||
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
|
|
||||||
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
|
|
||||||
return order === 'desc' ? bv - av : av - bv
|
|
||||||
})
|
|
||||||
}, [data, filter, filterKey, sort, order, statusFilter, statusFilterKey])
|
|
||||||
|
|
||||||
const total = filtered.length
|
|
||||||
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
|
||||||
{filterKey && (
|
|
||||||
<select value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
|
||||||
<option value="">{filterLabel || '全部'}</option>
|
|
||||||
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{statusFilterKey && statusOptions && (
|
|
||||||
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
|
|
||||||
<option value="">{statusFilterLabel || '全部状态'}</option>
|
|
||||||
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<span className="mx-1 text-muted-foreground">|</span>
|
|
||||||
{sortOptions.map(s => (
|
|
||||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`rounded px-3 py-1 text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
|
||||||
按{s.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="mx-1 text-muted-foreground">|</span>
|
|
||||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="rounded bg-muted px-3 py-1 text-xs">
|
|
||||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<DataTable columns={columns} data={paged} onRowClick={onRowClick} />
|
|
||||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'health', label: '健康度评分', icon: Activity },
|
{ key: 'health', label: '健康度评分', icon: Activity },
|
||||||
{ key: 'alerts', label: '预警中心', icon: AlertTriangle },
|
{ key: 'alerts', label: '预警中心', icon: AlertTriangle },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MetricCard } from '@/components/MetricCard'
|
|||||||
import { Badge } from '@/components/Badge'
|
import { Badge } from '@/components/Badge'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
import { FilterableTable } from '@/components/FilterableTable'
|
||||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -258,15 +259,24 @@ export function StoreDetailPage() {
|
|||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<h2 className="mb-3 text-sm font-bold">原料分类成本对标</h2>
|
<h2 className="mb-3 text-sm font-bold">原料分类成本对标</h2>
|
||||||
{cost.categories?.length > 0 ? (
|
{cost.categories?.length > 0 ? (
|
||||||
<DataTable
|
<FilterableTable
|
||||||
|
data={cost.categories}
|
||||||
|
filterKey="category_name"
|
||||||
|
filterLabel="全部分类"
|
||||||
|
sortOptions={[
|
||||||
|
{ key: 'consumption_amount', label: '消耗金额' },
|
||||||
|
{ key: 'actual_cost_rate_pct', label: '实际占比' },
|
||||||
|
{ key: 'benchmark_rate_pct', label: '公司基准' },
|
||||||
|
{ key: 'variance_pct', label: '差异' },
|
||||||
|
]}
|
||||||
|
defaultSort="consumption_amount"
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'category_name', label: '原料分类' },
|
{ key: 'category_name', label: '原料分类' },
|
||||||
{ key: 'consumption_amount', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_amount) },
|
{ key: 'consumption_amount', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_amount) },
|
||||||
{ key: 'actual_cost_rate_pct', label: '占比', align: 'right', render: (r) => formatPercent(r.actual_cost_rate_pct) },
|
{ key: 'actual_cost_rate_pct', label: '实际占比', align: 'right', render: (r) => formatPercent(r.actual_cost_rate_pct) },
|
||||||
{ key: 'benchmark_rate_pct', label: '公司基准', align: 'right', render: (r) => formatPercent(r.benchmark_rate_pct) },
|
{ key: 'benchmark_rate_pct', label: '公司基准', align: 'right', render: (r) => formatPercent(r.benchmark_rate_pct) },
|
||||||
{ key: 'variance_pct', label: '差异', align: 'right', render: (r) => <span className={Number(r.variance_pct) > 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}%</span> },
|
{ key: 'variance_pct', label: '差异', align: 'right', render: (r) => <span className={Number(r.variance_pct) > 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}%</span> },
|
||||||
]}
|
]}
|
||||||
data={cost.categories}
|
|
||||||
/>
|
/>
|
||||||
) : <p className="text-sm text-muted-foreground">暂无分类数据</p>}
|
) : <p className="text-sm text-muted-foreground">暂无分类数据</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user