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}
/>
) : 暂无分类数据
}