风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距
This commit is contained in:
@@ -1,39 +1,25 @@
|
||||
import { useState, useMemo } 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 { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const LEVEL_BG: Record<string, string> = {
|
||||
red: 'bg-red-100 text-red-700 border-red-300',
|
||||
orange: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
}
|
||||
|
||||
export function AttendanceAlertTab() {
|
||||
const [summaryPage, setSummaryPage] = useState(1)
|
||||
const [alertPage, setAlertPage] = useState(1)
|
||||
const [summaryFilter, setSummaryFilter] = useState('')
|
||||
const [summarySort, setSummarySort] = useState('emp_count')
|
||||
const [summaryOrder, setSummaryOrder] = useState('desc')
|
||||
const [alertFilter, setAlertFilter] = useState('')
|
||||
const [alertLevelFilter, setAlertLevelFilter] = useState('')
|
||||
const [alertSort, setAlertSort] = useState('attend_diff')
|
||||
const [alertOrder, setAlertOrder] = useState('desc')
|
||||
|
||||
export function AttendanceAlertTab({ month }: { month: string }) {
|
||||
const { data: summaryData, isLoading: summaryLoading } = useQuery({
|
||||
queryKey: ['ss/attendance-summary'],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-summary'),
|
||||
queryKey: ['ss/attendance-summary', month],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-summary', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: alertData, isLoading: alertLoading } = useQuery({
|
||||
queryKey: ['ss/attendance-alert'],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-alert'),
|
||||
queryKey: ['ss/attendance-alert', month],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-alert', { params: { month } }),
|
||||
})
|
||||
|
||||
const summaryRows = (summaryData as any)?.data || []
|
||||
@@ -41,49 +27,6 @@ export function AttendanceAlertTab() {
|
||||
|
||||
const storeNames = [...new Set(summaryRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
const summarySorts = [
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'avg_attend_days', label: '均出勤天' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
{ key: 'total_absent_days', label: '旷工天数' },
|
||||
{ key: 'total_late_deduction', label: '迟到扣款' },
|
||||
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
|
||||
]
|
||||
|
||||
const summaryFiltered = useMemo(() => {
|
||||
let r = summaryRows
|
||||
if (summaryFilter) r = r.filter((s: any) => s.store_name === summaryFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[summarySort]) || 0
|
||||
const bv = parseFloat(b[summarySort]) || 0
|
||||
return summaryOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [summaryRows, summaryFilter, summarySort, summaryOrder])
|
||||
|
||||
const summaryTotal = summaryFiltered.length
|
||||
const summaryPaged = summaryFiltered.slice((summaryPage - 1) * PAGE_SIZE, summaryPage * PAGE_SIZE)
|
||||
|
||||
const alertSorts = [
|
||||
{ key: 'attend_diff', label: '偏差天' },
|
||||
{ key: 'absent_days', label: '旷工天' },
|
||||
{ key: 'late_deduction', label: '迟到扣款' },
|
||||
{ key: 'actual_hours', label: '实际工时' },
|
||||
]
|
||||
|
||||
const alertFiltered = useMemo(() => {
|
||||
let r = alertRows
|
||||
if (alertFilter) r = r.filter((s: any) => s.store_name === alertFilter)
|
||||
if (alertLevelFilter) r = r.filter((s: any) => s.alert_level === alertLevelFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[alertSort]) || 0
|
||||
const bv = parseFloat(b[alertSort]) || 0
|
||||
return alertOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [alertRows, alertFilter, alertLevelFilter, alertSort, alertOrder])
|
||||
|
||||
const alertTotal = alertFiltered.length
|
||||
const alertPaged = alertFiltered.slice((alertPage - 1) * PAGE_SIZE, alertPage * PAGE_SIZE)
|
||||
|
||||
const redCount = alertRows.filter((r: any) => r.alert_level === 'red').length
|
||||
const orangeCount = alertRows.filter((r: any) => r.alert_level === 'orange').length
|
||||
const yellowCount = alertRows.filter((r: any) => r.alert_level === 'yellow').length
|
||||
@@ -108,21 +51,20 @@ export function AttendanceAlertTab() {
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="门店考勤汇总" subtitle="各门店出勤天数、旷工、迟到扣款、低出勤率">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={summaryFilter} onChange={(e) => { setSummaryFilter(e.target.value); setSummaryPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{summarySorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSummarySort(s.key); setSummaryPage(1) }} className={`px-3 py-1 rounded text-xs ${summarySort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setSummaryOrder(summaryOrder === 'asc' ? 'desc' : 'asc'); setSummaryPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{summaryOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={summaryRows}
|
||||
sortOptions={[
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'avg_attend_days', label: '均出勤天' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
{ key: 'total_absent_days', label: '旷工天数' },
|
||||
{ key: 'total_late_deduction', label: '迟到扣款' },
|
||||
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
|
||||
]}
|
||||
defaultSort="emp_count"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
@@ -134,33 +76,29 @@ export function AttendanceAlertTab() {
|
||||
{ key: 'punch_issue_count', label: '打卡异常人数', align: 'right', render: (r) => <span className={parseFloat(r.punch_issue_count) > 5 ? 'text-orange-600' : ''}>{r.punch_issue_count || 0}</span> },
|
||||
{ key: 'low_attendance_rate_pct', label: '低出勤率%', align: 'right', render: (r) => <span className={parseFloat(r.low_attendance_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{r.low_attendance_rate_pct ? r.low_attendance_rate_pct + '%' : '0%'}</span> },
|
||||
]}
|
||||
data={summaryPaged}
|
||||
/>
|
||||
<Pagination page={summaryPage} pageSize={PAGE_SIZE} total={summaryTotal} onPageChange={setSummaryPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="考勤异常明细" subtitle="员工级考勤预警列表(按严重程度排序)">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={alertFilter} onChange={(e) => { setAlertFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={alertLevelFilter} onChange={(e) => { setAlertLevelFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部等级</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="orange">橙色</option>
|
||||
<option value="yellow">黄色</option>
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{alertSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setAlertSort(s.key); setAlertPage(1) }} className={`px-3 py-1 rounded text-xs ${alertSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setAlertOrder(alertOrder === 'asc' ? 'desc' : 'asc'); setAlertPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{alertOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={alertRows}
|
||||
sortOptions={[
|
||||
{ key: 'attend_diff', label: '偏差天' },
|
||||
{ key: 'absent_days', label: '旷工天' },
|
||||
{ key: 'late_deduction', label: '迟到扣款' },
|
||||
{ key: 'actual_hours', label: '实际工时' },
|
||||
]}
|
||||
defaultSort="attend_diff"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
statusFilterKey="alert_level"
|
||||
statusFilterLabel="全部等级"
|
||||
statusOptions={[
|
||||
{ value: 'red', label: '红色' },
|
||||
{ value: 'orange', label: '橙色' },
|
||||
{ value: 'yellow', label: '黄色' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'employee_code', label: '工号' },
|
||||
@@ -174,9 +112,7 @@ export function AttendanceAlertTab() {
|
||||
{ key: 'actual_hours', label: '实际工时', align: 'right', render: (r) => formatNumber(r.actual_hours) },
|
||||
{ key: 'alert_type', label: '预警类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs border ${LEVEL_BG[r.alert_level] || ''}`}>{r.alert_type}</span> },
|
||||
]}
|
||||
data={alertPaged}
|
||||
/>
|
||||
<Pagination page={alertPage} pageSize={PAGE_SIZE} total={alertTotal} onPageChange={setAlertPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,82 +1,57 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
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 { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function EfficiencyBenchmarkTab() {
|
||||
export function EfficiencyBenchmarkTab({ month }: { month: string }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('revenue_per_emp')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [posPage, setPosPage] = useState(1)
|
||||
const [posFilter, setPosFilter] = useState('')
|
||||
const [posSort, setPosSort] = useState('total_emp')
|
||||
const [posOrder, setPosOrder] = useState('desc')
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/efficiency', page, sort, order],
|
||||
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}`),
|
||||
queryKey: ['ss/efficiency', page, sort, order, month],
|
||||
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking`, { params: { page, page_size: PAGE_SIZE, sort, order, month } }),
|
||||
})
|
||||
|
||||
const { data: posData } = useQuery({
|
||||
queryKey: ['ss/position-distribution'],
|
||||
queryFn: () => api.get('/smart-scheduling/position-distribution'),
|
||||
queryKey: ['ss/position-distribution', month],
|
||||
queryFn: () => api.get('/smart-scheduling/position-distribution', { params: { month } }),
|
||||
})
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const posRows = (posData as any)?.data || []
|
||||
const posStoreNames = [...new Set(posRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
const posSorts = [
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
{ key: 'manager_pct', label: '管理占比' },
|
||||
{ key: 'kitchen_pct', label: '后厨占比' },
|
||||
{ key: 'front_pct', label: '前厅占比' },
|
||||
{ key: 'part_time_count', label: '兼职数' },
|
||||
]
|
||||
|
||||
const posFiltered = useMemo(() => {
|
||||
let r = posRows
|
||||
if (posFilter) r = r.filter((s: any) => s.store_name === posFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[posSort]) || 0
|
||||
const bv = parseFloat(b[posSort]) || 0
|
||||
return posOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [posRows, posFilter, posSort, posOrder])
|
||||
|
||||
const posTotal = posFiltered.length
|
||||
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载人效数据..." />
|
||||
|
||||
const sorts = [
|
||||
{ key: 'revenue_per_emp', label: '人均创收' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'wage_rate', label: '人力成本率' },
|
||||
{ key: 'avg_hours', label: '人均工时' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店人效排名" subtitle="人均创收、人力成本率、人均工时、加班占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{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>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={rows}
|
||||
serverSide
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
order={order}
|
||||
onOrderChange={setOrder}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={meta.total}
|
||||
sortOptions={[
|
||||
{ key: 'revenue_per_emp', label: '人均创收' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'wage_rate', label: '人力成本率' },
|
||||
{ key: 'avg_hours', label: '人均工时' },
|
||||
]}
|
||||
defaultSort="revenue_per_emp"
|
||||
defaultOrder="desc"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
@@ -89,27 +64,23 @@ export function EfficiencyBenchmarkTab() {
|
||||
{ key: 'revenue_per_hour', label: '时均创收', align: 'right', render: (r) => formatCurrency(r.revenue_per_hour) },
|
||||
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{formatPercent(r.overtime_rate)}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="岗位配比分析" subtitle="各门店前厅/后厨/管理岗位人数及占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={posFilter} onChange={(e) => { setPosFilter(e.target.value); setPosPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{posStoreNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{posSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={posRows}
|
||||
sortOptions={[
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
{ key: 'manager_pct', label: '管理占比' },
|
||||
{ key: 'kitchen_pct', label: '后厨占比' },
|
||||
{ key: 'front_pct', label: '前厅占比' },
|
||||
{ key: 'part_time_count', label: '兼职数' },
|
||||
]}
|
||||
defaultSort="total_emp"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
|
||||
@@ -121,9 +92,7 @@ export function EfficiencyBenchmarkTab() {
|
||||
{ key: 'front_pct', label: '前厅占比%', align: 'right', render: (r) => `${r.front_pct}%` },
|
||||
{ key: 'part_time_count', label: '兼职', align: 'right', render: (r) => formatNumber(r.part_time_count) },
|
||||
]}
|
||||
data={posPaged}
|
||||
/>
|
||||
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
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 { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
|
||||
@@ -15,105 +14,64 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
'在职': 'text-green-600',
|
||||
}
|
||||
|
||||
export function EmployeeAnalysisTab() {
|
||||
export function EmployeeAnalysisTab({ month }: { month: string }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('gross_pay')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [posPage, setPosPage] = useState(1)
|
||||
const [turnoverPage, setTurnoverPage] = useState(1)
|
||||
const [posSort, setPosSort] = useState('avg_gross')
|
||||
const [posOrder, setPosOrder] = useState('desc')
|
||||
const [turnoverSort, setTurnoverSort] = useState('turnover_rate')
|
||||
const [turnoverOrder, setTurnoverOrder] = useState('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/employee', page, sort, order, storeFilter],
|
||||
queryFn: () => api.get(`/smart-scheduling/employee-analysis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}${storeFilter ? `&store=${storeFilter}` : ''}`),
|
||||
queryKey: ['ss/employee', page, sort, order, storeFilter, month],
|
||||
queryFn: () => api.get(`/smart-scheduling/employee-analysis`, { params: { page, page_size: PAGE_SIZE, sort, order, month, ...(storeFilter ? { store: storeFilter } : {}) } }),
|
||||
})
|
||||
|
||||
const { data: posData } = useQuery({
|
||||
queryKey: ['ss/position-salary'],
|
||||
queryFn: () => api.get('/smart-scheduling/position-salary-compare'),
|
||||
queryKey: ['ss/position-salary', month],
|
||||
queryFn: () => api.get('/smart-scheduling/position-salary-compare', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: turnoverData } = useQuery({
|
||||
queryKey: ['ss/turnover'],
|
||||
queryFn: () => api.get('/smart-scheduling/turnover-stats'),
|
||||
queryKey: ['ss/turnover', month],
|
||||
queryFn: () => api.get('/smart-scheduling/turnover-stats', { params: { month } }),
|
||||
})
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const posRows = (posData as any)?.data || []
|
||||
const turnoverRows = (turnoverData as any)?.data || []
|
||||
|
||||
const posSorts = [
|
||||
{ key: 'avg_gross', label: '均应发' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
{ key: 'avg_hourly_rate', label: '均时薪' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
]
|
||||
|
||||
const posFiltered = useMemo(() => {
|
||||
return [...posRows].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[posSort]) || 0
|
||||
const bv = parseFloat(b[posSort]) || 0
|
||||
return posOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [posRows, posSort, posOrder])
|
||||
|
||||
const posTotal = posFiltered.length
|
||||
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
|
||||
|
||||
const turnoverSorts = [
|
||||
{ key: 'turnover_rate', label: '离职率' },
|
||||
{ key: 'left_count', label: '离职人数' },
|
||||
{ key: 'new_hire_rate', label: '新员工占比' },
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
]
|
||||
|
||||
const turnoverFiltered = useMemo(() => {
|
||||
return [...turnoverRows].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[turnoverSort]) || 0
|
||||
const bv = parseFloat(b[turnoverSort]) || 0
|
||||
return turnoverOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [turnoverRows, turnoverSort, turnoverOrder])
|
||||
|
||||
const turnoverTotal = turnoverFiltered.length
|
||||
const turnoverPaged = turnoverFiltered.slice((turnoverPage - 1) * PAGE_SIZE, turnoverPage * PAGE_SIZE)
|
||||
const storeNames = [...new Set(turnoverRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载员工数据..." />
|
||||
|
||||
const sorts = [
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'net_pay', label: '实发工资' },
|
||||
{ key: 'actual_hours', label: '工时' },
|
||||
{ key: 'perf_amount', label: '绩效' },
|
||||
{ key: 'overtime_pay', label: '加班费' },
|
||||
{ key: 'hourly_rate', label: '时薪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="员工薪资明细" subtitle="员工级薪资构成、工时、绩效、有效时薪">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s: any) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={rows}
|
||||
serverSide
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
order={order}
|
||||
onOrderChange={setOrder}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={meta.total}
|
||||
filter={storeFilter}
|
||||
onFilterChange={setStoreFilter}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
filterOptions={storeNames}
|
||||
sortOptions={[
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'net_pay', label: '实发工资' },
|
||||
{ key: 'actual_hours', label: '工时' },
|
||||
{ key: 'perf_amount', label: '绩效' },
|
||||
{ key: 'overtime_pay', label: '加班费' },
|
||||
{ key: 'hourly_rate', label: '时薪' },
|
||||
]}
|
||||
defaultSort="gross_pay"
|
||||
defaultOrder="desc"
|
||||
columns={[
|
||||
{ key: 'employee_code', label: '工号' },
|
||||
{ key: 'store_name', label: '门店' },
|
||||
@@ -129,22 +87,24 @@ export function EmployeeAnalysisTab() {
|
||||
{ key: 'effective_hourly_rate', label: '有效时薪', align: 'right', render: (r) => <span className={parseFloat(r.effective_hourly_rate) > 100 ? 'text-green-600' : parseFloat(r.effective_hourly_rate) < 50 ? 'text-red-600' : ''}>{formatCurrency(r.effective_hourly_rate)}</span> },
|
||||
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{r.overtime_rate}%</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="岗位薪资对比" subtitle="相同岗位在不同门店的薪资水平差异">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{posSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={posRows}
|
||||
filterKey="position"
|
||||
filterLabel="全部岗位"
|
||||
sortOptions={[
|
||||
{ key: 'avg_gross', label: '均应发' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
{ key: 'avg_hourly_rate', label: '均时薪' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
]}
|
||||
defaultSort="avg_gross"
|
||||
defaultOrder="desc"
|
||||
pageSize={PAGE_SIZE}
|
||||
columns={[
|
||||
{ key: 'position', label: '岗位' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
@@ -157,22 +117,22 @@ export function EmployeeAnalysisTab() {
|
||||
{ key: 'avg_perf_score', label: '均绩效分', align: 'right', render: (r) => r.avg_perf_score },
|
||||
{ key: 'avg_hourly_rate', label: '均有效时薪', align: 'right', render: (r) => formatCurrency(r.avg_hourly_rate) },
|
||||
]}
|
||||
data={posPaged}
|
||||
/>
|
||||
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="离职率统计" subtitle="各门店离职率、新员工占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{turnoverSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setTurnoverSort(s.key); setTurnoverPage(1) }} className={`px-3 py-1 rounded text-xs ${turnoverSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setTurnoverOrder(turnoverOrder === 'asc' ? 'desc' : 'asc'); setTurnoverPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{turnoverOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={turnoverRows}
|
||||
sortOptions={[
|
||||
{ key: 'turnover_rate', label: '离职率' },
|
||||
{ key: 'left_count', label: '离职人数' },
|
||||
{ key: 'new_hire_rate', label: '新员工占比' },
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
]}
|
||||
defaultSort="turnover_rate"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
|
||||
@@ -181,9 +141,7 @@ export function EmployeeAnalysisTab() {
|
||||
{ key: 'new_count', label: '新员工数', align: 'right', render: (r) => <span className="text-blue-500">{r.new_count}</span> },
|
||||
{ key: 'new_hire_rate', label: '新员工占比%', align: 'right', render: (r) => <span className={parseFloat(r.new_hire_rate) > 20 ? 'text-orange-600' : ''}>{r.new_hire_rate ? r.new_hire_rate + '%' : '0%'}</span> },
|
||||
]}
|
||||
data={turnoverPaged}
|
||||
/>
|
||||
<Pagination page={turnoverPage} pageSize={PAGE_SIZE} total={turnoverTotal} onPageChange={setTurnoverPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -18,10 +18,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
'离职': '人员稳定',
|
||||
}
|
||||
|
||||
export function OverallAnalysisTab() {
|
||||
export function OverallAnalysisTab({ month }: { month: string }) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/overall-analysis'],
|
||||
queryFn: () => api.get('/smart-scheduling/overall-analysis'),
|
||||
queryKey: ['ss/overall-analysis', month],
|
||||
queryFn: () => api.get('/smart-scheduling/overall-analysis', { params: { month } }),
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="生成总体智能分析..." />
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
@@ -56,7 +56,7 @@ const tableColumns = [
|
||||
{ key: 'action', label: '建议', render: (r: any) => <span className={ACTION_COLORS[r.action] || ''}>{r.action}</span> },
|
||||
]
|
||||
|
||||
export function SchedulingSuggestionTab() {
|
||||
export function SchedulingSuggestionTab({ month }: { month: string }) {
|
||||
const { data: storesData } = useQuery({
|
||||
queryKey: ['ss/stores'],
|
||||
queryFn: () => api.get('/smart-scheduling/stores'),
|
||||
@@ -67,8 +67,8 @@ export function SchedulingSuggestionTab() {
|
||||
const [kitchenTarget, setKitchenTarget] = useState(25)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget],
|
||||
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}`),
|
||||
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget, month],
|
||||
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}&month=${month}`),
|
||||
enabled: !!storeName,
|
||||
})
|
||||
|
||||
@@ -123,11 +123,35 @@ export function SchedulingSuggestionTab() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold mb-2">工作日排班建议</h3>
|
||||
<DataTable columns={tableColumns} data={workdayRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
|
||||
<FilterableTable
|
||||
data={workdayRows}
|
||||
sortOptions={[
|
||||
{ key: 'hour', label: '时段' },
|
||||
{ key: 'avg_daily_bills', label: '日均账单' },
|
||||
{ key: 'staff_gap', label: '总缺口' },
|
||||
]}
|
||||
defaultSort="hour"
|
||||
defaultOrder="asc"
|
||||
filterKey="action"
|
||||
filterLabel="全部建议"
|
||||
columns={tableColumns}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold mb-2">周末排班建议</h3>
|
||||
<DataTable columns={tableColumns} data={weekendRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
|
||||
<FilterableTable
|
||||
data={weekendRows}
|
||||
sortOptions={[
|
||||
{ key: 'hour', label: '时段' },
|
||||
{ key: 'avg_daily_bills', label: '日均账单' },
|
||||
{ key: 'staff_gap', label: '总缺口' },
|
||||
]}
|
||||
defaultSort="hour"
|
||||
defaultOrder="asc"
|
||||
filterKey="action"
|
||||
filterLabel="全部建议"
|
||||
columns={tableColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -20,7 +20,7 @@ const LEVEL_ROW_STYLES: Record<string, string> = {
|
||||
yellow: 'bg-yellow-50/30',
|
||||
}
|
||||
|
||||
export function StaffingForecastTab() {
|
||||
export function StaffingForecastTab({ month }: { month: string }) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
@@ -30,8 +30,8 @@ export function StaffingForecastTab() {
|
||||
const [showRules, setShowRules] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/staffing-forecast'],
|
||||
queryFn: () => api.get('/smart-scheduling/staffing-forecast'),
|
||||
queryKey: ['ss/staffing-forecast', month],
|
||||
queryFn: () => api.get('/smart-scheduling/staffing-forecast', { params: { month } }),
|
||||
})
|
||||
|
||||
const result = (data as any)?.data || {}
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
@@ -14,7 +13,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
'过剩': 'text-gray-400',
|
||||
}
|
||||
|
||||
export function StaffingMatchTab() {
|
||||
export function StaffingMatchTab({ month }: { month: string }) {
|
||||
const { data: storesData } = useQuery({
|
||||
queryKey: ['ss/stores'],
|
||||
queryFn: () => api.get('/smart-scheduling/stores'),
|
||||
@@ -23,8 +22,8 @@ export function StaffingMatchTab() {
|
||||
const [storeName, setStoreName] = useState('七里庄店')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/staffing-match', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/staffing-match?store=${storeName}`),
|
||||
queryKey: ['ss/staffing-match', storeName, month],
|
||||
queryFn: () => api.get('/smart-scheduling/staffing-match', { params: { store: storeName, month } }),
|
||||
enabled: !!storeName,
|
||||
})
|
||||
|
||||
|
||||
@@ -2,42 +2,31 @@ import { useState, useMemo, Fragment } 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 { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function TrafficHeatmapTab() {
|
||||
export function TrafficHeatmapTab({ month }: { month: string }) {
|
||||
const [storeName, setStoreName] = useState('')
|
||||
const [overviewPage, setOverviewPage] = useState(1)
|
||||
const [mealPage, setMealPage] = useState(1)
|
||||
const [overviewSort, setOverviewSort] = useState('total_bills')
|
||||
const [overviewOrder, setOverviewOrder] = useState('desc')
|
||||
const [overviewFilter, setOverviewFilter] = useState('')
|
||||
const [mealSort, setMealSort] = useState('total')
|
||||
const [mealOrder, setMealOrder] = useState('desc')
|
||||
const [mealFilter, setMealFilter] = useState('')
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['ss/traffic-overview'],
|
||||
queryFn: () => api.get('/smart-scheduling/traffic-overview'),
|
||||
queryKey: ['ss/traffic-overview', month],
|
||||
queryFn: () => api.get('/smart-scheduling/traffic-overview', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: heatmap, isLoading } = useQuery({
|
||||
queryKey: ['ss/traffic-heatmap', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
|
||||
queryKey: ['ss/traffic-heatmap', storeName, month],
|
||||
queryFn: () => api.get('/smart-scheduling/traffic-heatmap', { params: { store: storeName || undefined, month } }),
|
||||
})
|
||||
|
||||
const { data: staffing } = useQuery({
|
||||
queryKey: ['ss/traffic-heatmap-staffing', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap-staffing${storeName ? `?store=${storeName}` : ''}`),
|
||||
queryKey: ['ss/traffic-heatmap-staffing', storeName, month],
|
||||
queryFn: () => api.get('/smart-scheduling/traffic-heatmap-staffing', { params: { store: storeName || undefined, month } }),
|
||||
})
|
||||
|
||||
const { data: mealPeriod } = useQuery({
|
||||
queryKey: ['ss/meal-period-traffic', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
|
||||
queryKey: ['ss/meal-period-traffic', storeName, month],
|
||||
queryFn: () => api.get('/smart-scheduling/meal-period-traffic', { params: { store: storeName || undefined, month } }),
|
||||
})
|
||||
|
||||
const stores = (overview as any)?.data || []
|
||||
@@ -80,29 +69,8 @@ export function TrafficHeatmapTab() {
|
||||
mealMap[r.store_name][r.meal_period] = r.bills
|
||||
})
|
||||
|
||||
const overviewSorts = [
|
||||
{ key: 'total_bills', label: '月账单量' },
|
||||
{ key: 'peak_bills', label: '峰值' },
|
||||
{ key: 'peak_valley_ratio', label: '峰谷比' },
|
||||
{ key: 'peak_concentration_pct', label: '集中度' },
|
||||
]
|
||||
|
||||
const overviewFiltered = useMemo(() => {
|
||||
let r = stores
|
||||
if (overviewFilter) r = r.filter((s: any) => s.store_name.includes(overviewFilter))
|
||||
r = [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[overviewSort]) || 0
|
||||
const bv = parseFloat(b[overviewSort]) || 0
|
||||
return overviewOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
return r
|
||||
}, [stores, overviewFilter, overviewSort, overviewOrder])
|
||||
|
||||
const overviewTotal = overviewFiltered.length
|
||||
const overviewPaged = overviewFiltered.slice((overviewPage - 1) * PAGE_SIZE, overviewPage * PAGE_SIZE)
|
||||
|
||||
const mealStores = useMemo(() => {
|
||||
let r = Object.keys(mealMap).map(s => ({
|
||||
return Object.keys(mealMap).map(s => ({
|
||||
store_name: s,
|
||||
早市: mealMap[s]?.['早市'] || 0,
|
||||
午市: mealMap[s]?.['午市'] || 0,
|
||||
@@ -111,25 +79,7 @@ export function TrafficHeatmapTab() {
|
||||
夜宵: mealMap[s]?.['夜宵'] || 0,
|
||||
total: Object.values(mealMap[s] || {}).reduce((a: number, b: number) => a + b, 0),
|
||||
}))
|
||||
if (mealFilter) r = r.filter((s: any) => s.store_name.includes(mealFilter))
|
||||
r = [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[mealSort]) || 0
|
||||
const bv = parseFloat(b[mealSort]) || 0
|
||||
return mealOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
return r
|
||||
}, [mealMap, mealFilter, mealSort, mealOrder])
|
||||
|
||||
const mealTotal = mealStores.length
|
||||
const mealPaged = mealStores.slice((mealPage - 1) * PAGE_SIZE, mealPage * PAGE_SIZE)
|
||||
|
||||
const mealSorts = [
|
||||
{ key: 'total', label: '总账单' },
|
||||
{ key: '午市', label: '午市' },
|
||||
{ key: '晚市', label: '晚市' },
|
||||
{ key: '早市', label: '早市' },
|
||||
{ key: '夜宵', label: '夜宵' },
|
||||
]
|
||||
}, [mealMap])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -182,21 +132,18 @@ export function TrafficHeatmapTab() {
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="门店客流概览" subtitle="各门店月度账单量、峰谷比、客流集中度">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={overviewFilter} onChange={(e) => { setOverviewFilter(e.target.value); setOverviewPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{overviewSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setOverviewSort(s.key); setOverviewPage(1) }} className={`px-3 py-1 rounded text-xs ${overviewSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOverviewOrder(overviewOrder === 'asc' ? 'desc' : 'asc'); setOverviewPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{overviewOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={stores}
|
||||
sortOptions={[
|
||||
{ key: 'total_bills', label: '月账单量' },
|
||||
{ key: 'peak_bills', label: '峰值' },
|
||||
{ key: 'peak_valley_ratio', label: '峰谷比' },
|
||||
{ key: 'peak_concentration_pct', label: '集中度' },
|
||||
]}
|
||||
defaultSort="total_bills"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_bills', label: '月账单量', align: 'right', render: (r) => formatNumber(r.total_bills) },
|
||||
@@ -206,27 +153,23 @@ export function TrafficHeatmapTab() {
|
||||
{ key: 'peak_concentration_pct', label: '高峰集中度%', align: 'right', render: (r) => <span className={parseFloat(r.peak_concentration_pct) > 60 ? 'text-orange-600 font-medium' : ''}>{r.peak_concentration_pct}%</span> },
|
||||
{ key: 'volatility_level', label: '波动等级', render: (r) => <span className={r.volatility_level === '波动极大' ? 'text-red-600' : r.volatility_level === '波动较大' ? 'text-orange-600' : ''}>{r.volatility_level}</span> },
|
||||
]}
|
||||
data={overviewPaged}
|
||||
/>
|
||||
<Pagination page={overviewPage} pageSize={PAGE_SIZE} total={overviewTotal} onPageChange={setOverviewPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="餐段客流分布" subtitle="各门店早市/午市/下午茶/晚市/夜宵账单占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={mealFilter} onChange={(e) => { setMealFilter(e.target.value); setMealPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{mealSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setMealSort(s.key); setMealPage(1) }} className={`px-3 py-1 rounded text-xs ${mealSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setMealOrder(mealOrder === 'asc' ? 'desc' : 'asc'); setMealPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{mealOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
<FilterableTable
|
||||
data={mealStores}
|
||||
sortOptions={[
|
||||
{ key: 'total', label: '总账单' },
|
||||
{ key: '午市', label: '午市' },
|
||||
{ key: '晚市', label: '晚市' },
|
||||
{ key: '早市', label: '早市' },
|
||||
{ key: '夜宵', label: '夜宵' },
|
||||
]}
|
||||
defaultSort="total"
|
||||
defaultOrder="desc"
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: '早市', label: '早市', align: 'right', render: (r) => formatNumber(r['早市']) },
|
||||
@@ -235,9 +178,7 @@ export function TrafficHeatmapTab() {
|
||||
{ key: '晚市', label: '晚市', align: 'right', render: (r) => formatNumber(r['晚市']) },
|
||||
{ key: '夜宵', label: '夜宵', align: 'right', render: (r) => formatNumber(r['夜宵']) },
|
||||
]}
|
||||
data={mealPaged}
|
||||
/>
|
||||
<Pagination page={mealPage} pageSize={PAGE_SIZE} total={mealTotal} onPageChange={setMealPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user