diff --git a/client/src/components/FilterableTable.tsx b/client/src/components/FilterableTable.tsx new file mode 100644 index 0000000..6030d85 --- /dev/null +++ b/client/src/components/FilterableTable.tsx @@ -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 ( + <> +
+ {filterKey && ( + + )} + {statusFilterKey && statusOptions && ( + + )} + | + {sortOptions.map(s => ( + + ))} + | + +
+ + + + ) +} diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index be2635c..4fe75e9 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -6,93 +6,16 @@ import api from '@/lib/api' import { CollapsibleSection } from '@/components/CollapsibleSection' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { Badge } from '@/components/Badge' import { formatCurrency, cn } from '@/lib/utils' import { Activity, AlertTriangle, Link2, TrendingUp } from 'lucide-react' -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 }[] -} - -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 ( - <> -
- {filterKey && ( - - )} - {statusFilterKey && statusOptions && ( - - )} - | - {sortOptions.map(s => ( - - ))} - | - -
- - - - ) -} - const TABS = [ { key: 'health', label: '健康度评分', icon: Activity }, { key: 'alerts', label: '预警中心', icon: AlertTriangle }, diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx index ea31aea..6839366 100644 --- a/client/src/pages/StoreDetailPage.tsx +++ b/client/src/pages/StoreDetailPage.tsx @@ -6,6 +6,7 @@ import { MetricCard } from '@/components/MetricCard' import { Badge } from '@/components/Badge' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' import { ArrowLeft } from 'lucide-react' import { useState } from 'react' @@ -258,15 +259,24 @@ export function StoreDetailPage() {

原料分类成本对标

{cost.categories?.length > 0 ? ( - 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: 'variance_pct', label: '差异', align: 'right', render: (r) => 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}% }, ]} - data={cost.categories} /> ) :

暂无分类数据

}