风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距

This commit is contained in:
freedakgmail
2026-08-01 21:28:15 +08:00
parent ac8e39e2f9
commit d724a565f5
60 changed files with 3154 additions and 2561 deletions
@@ -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>
)