Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
+198 -32
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2 } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -9,6 +9,8 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import { InlineAlert } from '../components/ui/InlineAlert'
import { useConfirm } from '../hooks/useConfirm'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock },
@@ -90,19 +92,23 @@ export default function Attendance() {
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [filterDepartment, setFilterDepartment] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month, filterDepartment],
queryKey: ['attendance', month, filterDepartment, filterStatus],
queryFn: async () => {
const params: any = { month }
if (filterDepartment) params.department = filterDepartment
if (filterStatus) params.status = filterStatus
const res = await api.get('/attendance', { params }) as any
return res.data
},
@@ -156,38 +162,178 @@ function ConfirmTab() {
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消失败'),
})
const batchConfirmMutation = useMutation({
mutationFn: async (params: { all?: boolean; ids?: string[] }) => {
const res = await api.post('/attendance/batch-confirm', { month, ...params }) as any
return res.data
},
onSuccess: (data: any) => {
toast.success(`已批量确认 ${data.count} 条考勤记录`)
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
setSelectedIds(new Set())
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '批量确认失败'),
})
const singleConfirmMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.post('/attendance/confirm', { employeeId: list.find((i: any) => i.id === id)?.employeeId, month }) as any
return res.data
},
onSuccess: () => {
toast.success('已确认')
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'),
})
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
const pendingCount = stats?.pending || 0
const pendingItems = (list || []).filter((i: any) => i.status === 'PENDING')
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id))
const toggleSelect = (id: string) => {
const next = new Set(selectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelectedIds(next)
}
const toggleSelectAllPending = () => {
if (allPendingSelected) {
const next = new Set(selectedIds)
pendingItems.forEach((i: any) => next.delete(i.id))
setSelectedIds(next)
} else {
const next = new Set(selectedIds)
pendingItems.forEach((i: any) => next.add(i.id))
setSelectedIds(next)
}
}
// 流程步骤
const FLOW_STEPS = [
{ label: '导入考勤', desc: 'Excel 批量导入', done: (list?.length || 0) > 0 },
{ label: 'HR 确认', desc: `待确认 ${pendingCount}`, done: pendingCount === 0 && (list?.length || 0) > 0 },
{ label: '发布考勤表', desc: currentPublish ? '已发布' : '未发布', done: !!currentPublish },
{ label: '员工确认', desc: stats ? `已确认 ${stats.confirmed}/${stats.total}` : '', done: stats?.confirmed === stats?.total && stats?.total > 0 },
]
return (
<div className="space-y-3">
<div className="flex items-center gap-2 justify-end">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
<X className="w-3.5 h-3.5 mr-1" />
{/* 流程指示器 */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{FLOW_STEPS.map((step, i) => (
<div key={i} className="flex items-center gap-1 shrink-0">
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs ${step.done ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'}`}>
{step.done ? <CheckCircle className="w-3.5 h-3.5" /> : <Clock className="w-3.5 h-3.5" />}
<span className="font-medium">{step.label}</span>
<span className="text-gray-400">{step.desc}</span>
</div>
{i < FLOW_STEPS.length - 1 && <span className="text-gray-300"></span>}
</div>
))}
</div>
{/* 指引提示 */}
{!currentPublish && pendingCount > 0 && (
<InlineAlert type="info" title="考勤确认流程">
</InlineAlert>
)}
{currentPublish && (
<InlineAlert type="success" title="考勤表已发布">
{month} {stats?.confirmed || 0}/{stats?.total || 0}
</InlineAlert>
)}
{stats?.disputed > 0 && (
<InlineAlert type="warning" title="有员工提出异议">
{stats.disputed}
</InlineAlert>
)}
<div className="flex items-center gap-2 justify-between flex-wrap">
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<>
<Button
size="sm"
variant="secondary"
onClick={toggleSelectAllPending}
>
{allPendingSelected ? '取消全选' : '全选待确认'}
</Button>
{selectedIds.size > 0 && (
<Button
size="sm"
onClick={async () => {
const ok = await confirm({ title: '批量确认考勤', message: `确认将选中的 ${selectedIds.size} 条考勤记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ ids: Array.from(selectedIds) })
}}
disabled={batchConfirmMutation.isPending}
>
<CheckCheck className="w-3.5 h-3.5 mr-1" />
({selectedIds.size})
</Button>
)}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ok = await confirm({ title: '全部确认', message: `确认将全部 ${pendingCount} 条待确认记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ all: true })
}}
disabled={batchConfirmMutation.isPending}
>
({pendingCount})
</Button>
</>
)}
</div>
<div className="flex items-center gap-2">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={async () => {
const ok = await confirm({ title: '取消发布', message: '取消发布后员工端将无法查看该月考勤表,确定操作?' })
if (ok) cancelPublishMutation.mutate(currentPublish.id)
}}>
<X className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending || pendingCount > 0}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="CONFIRMED"></option>
<option value="DISPUTED"></option>
</select>
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{stats && (
@@ -215,10 +361,19 @@ function ConfirmTab() {
{list.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
return (
<Card key={item.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{item.status === 'PENDING' && (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(item.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary shrink-0"
/>
)}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-50 flex-shrink-0">
<CalendarCheck className="w-4 h-4 text-gray-600" />
</div>
@@ -239,9 +394,20 @@ function ConfirmTab() {
)}
</div>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'PENDING' && (
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
</div>
</div>
</div>
</Card>
+179
View File
@@ -0,0 +1,179 @@
/**
* 薪酬分析看板 — 展示薪酬分布、部门对比、同比环比趋势
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import api from '../lib/api'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
const res = await api.get('/salary/dashboard', { params: { year } }) as any
return res.data
},
})
const departments = data?.departments || []
const monthlyTrend = data?.monthlyTrend || []
const summary = data?.summary || {}
/** 计算最大值用于柱状图比例 */
const maxDeptAvg = useMemo(() => {
if (departments.length === 0) return 1
return Math.max(...departments.map((d: any) => d.avgSalary || 0), 1)
}, [departments])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
<Select value={year} onChange={(e) => setYear(e.target.value)} className="!w-24">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !data ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="p-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">{summary.totalEmployees || 0}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.avgSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.medianSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-amber-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.totalAnnual)}</div>
</Card>
</div>
{/* 同比环比 */}
{summary.yoy !== undefined && (
<div className="flex gap-3">
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
</div>
</Card>
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{departments.map((dept: any) => (
<div key={dept.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">{dept.name}</span>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{dept.count}</span>
<span className="font-medium text-gray-700">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${(dept.avgSalary / maxDeptAvg) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</Card>
{/* 月度趋势 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="flex items-end gap-2 h-40">
{monthlyTrend.map((m: any) => {
const maxVal = Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
const height = ((m.total || 0) / maxVal) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="text-xs text-gray-400">{m.total ? `¥${(m.total / 10000).toFixed(1)}` : ''}</div>
<div className="w-full bg-gray-100 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-primary/70 rounded-t-md transition-all hover:bg-primary"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-xs text-gray-400">{m.month}</div>
</div>
)
})}
</div>
)}
</Card>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
</InlineAlert>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,206 @@
/**
* 统一风险中心 — 汇总展示合同风险、薪酬风险、社保风险、合规风险
* 按风险等级分类,支持快速跳转处理
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { InlineAlert } from '../../components/ui/InlineAlert'
import api from '../../lib/api'
/** 风险等级配置 */
const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }> = {
HIGH: { label: '高风险', color: 'text-rose-600', bg: 'bg-rose-50 border-rose-200' },
MEDIUM: { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50 border-amber-200' },
LOW: { label: '低风险', color: 'text-blue-600', bg: 'bg-blue-50 border-blue-200' },
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT_EXPIRE: { label: '合同到期', icon: FileText, link: '/roster' },
CONTRACT_UNSIGNED: { label: '未签合同', icon: FileText, link: '/roster' },
PROBATION_EXPIRE: { label: '试用期到期', icon: Clock, link: '/roster' },
SALARY_BELOW_MIN: { label: '工资低于最低标准', icon: TrendingDown, link: '/money' },
SOCIAL_INSURANCE_GAP: { label: '社保断缴', icon: ShieldAlert, link: '/social' },
TERMINATION_RISK: { label: '离职风险', icon: Users, link: '/termination' },
POLICY_UNREAD: { label: '制度未阅读', icon: FileText, link: '/policies' },
}
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
/** 获取风险列表 */
const { data: risks = [], isLoading } = useQuery<any[]>({
queryKey: ['risk-center'],
queryFn: async () => {
const res = await api.get('/compliance/risks') as any
return res.data || []
},
})
/** 按级别统计 */
const stats = useMemo(() => {
const high = risks.filter((r: any) => r.level === 'HIGH').length
const medium = risks.filter((r: any) => r.level === 'MEDIUM').length
const low = risks.filter((r: any) => r.level === 'LOW').length
return { high, medium, low, total: risks.length }
}, [risks])
/** 按类型统计 */
const typeStats = useMemo(() => {
const map: Record<string, number> = {}
risks.forEach((r: any) => {
map[r.type] = (map[r.type] || 0) + 1
})
return map
}, [risks])
/** 筛选后的风险列表 */
const filteredRisks = useMemo(() => {
return risks.filter((r: any) => {
if (filterLevel !== 'ALL' && r.level !== filterLevel) return false
if (filterType !== 'ALL' && r.type !== filterType) return false
return true
})
}, [risks, filterLevel, filterType])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
</div>
{/* 风险概览卡片 */}
<div className="grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-rose-600 mt-1">{stats.high}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-amber-600 mt-1">{stats.medium}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-blue-600 mt-1">{stats.low}</div>
</Card>
</div>
{/* 高风险告警 */}
{stats.high > 0 && (
<InlineAlert type="error">
{stats.high}
</InlineAlert>
)}
{/* 风险类型分布 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(RISK_TYPES).map(([key, cfg]) => {
const count = typeStats[key] || 0
if (count === 0) return null
const Icon = cfg.icon
return (
<Link
key={key}
to={cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600 truncate">{cfg.label}</div>
<div className="text-sm font-bold text-gray-900">{count}</div>
</div>
<ChevronRight className="w-3 h-3 text-gray-300" />
</Link>
)
})}
{Object.values(typeStats).every((v) => v === 0) && (
<div className="col-span-full text-center py-4 text-sm text-gray-400"></div>
)}
</div>
</Card>
{/* 筛选器 */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Filter className="w-4 h-4" />
</div>
<div className="flex gap-1">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel('ALL')}
></button>
{Object.entries(RISK_LEVELS).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel(key)}
>{cfg.label}</button>
))}
</div>
</div>
{/* 风险列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : filteredRisks.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
return (
<Link
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.message}</div>
{r.employeeName && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employeeName} · {r.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
)
})}
</div>
)}
</Card>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
/**
* 员工 Hub 首页 — 员工端统一入口
* 展示个人概览、待办事项、快捷入口、公司公告
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText,
TrendingUp, Clock, AlertCircle, ChevronRight,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 员工端 API 实例(自动携带 portalToken */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 快捷入口配置 */
const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
]
export default function EmployeeHome() {
const employee = (() => {
try { return JSON.parse(localStorage.getItem('portalEmployee') || '{}') } catch { return {} }
})()
/** 获取员工首页概览数据 */
const { data: overview, isLoading } = useQuery<any>({
queryKey: ['portal-home-overview'],
queryFn: async () => {
const res = await portalApi.get('/home/overview') as any
return res.data
},
})
if (isLoading) {
return <div className="space-y-4">
<div className="h-28 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-32 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
}
const latestPayslip = overview?.latestPayslip
const contractInfo = overview?.contract
const pendingTasks = overview?.pendingTasks || []
const announcements = overview?.announcements || []
const attendanceSummary = overview?.attendance
return (
<div className="space-y-4">
{/* 欢迎卡片 */}
<Card className="bg-gradient-to-br from-indigo-600 to-indigo-700 text-white border-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{employee.name || '同事'}</h1>
<p className="text-sm text-indigo-100 mt-1">{employee.department || ''} · {employee.position || ''}</p>
</div>
<div className="text-right">
<div className="text-xs text-indigo-100"></div>
<div className="text-xl font-bold">¥{fmt(latestPayslip?.netPay || 0)}</div>
</div>
</div>
</Card>
{/* 待办提醒 */}
{pendingTasks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-700 flex items-center gap-1">
<AlertCircle className="w-4 h-4 text-amber-500" />
{pendingTasks.length}
</h2>
{pendingTasks.map((task: any, i: number) => (
<InlineAlert key={i} type={task.severity === 'high' ? 'error' : 'warning'} className="text-xs">
{task.message}
</InlineAlert>
))}
</div>
)}
{/* 快捷入口 */}
<div>
<h2 className="text-sm font-medium text-gray-700 mb-2"></h2>
<div className="grid grid-cols-4 gap-3">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon
return (
<Link
key={action.path}
to={action.path}
className="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-white border border-gray-100 hover:shadow-sm transition-shadow"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${action.color}`}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-gray-600">{action.label}</span>
</Link>
)
})}
</div>
</div>
{/* 最新工资条 */}
{latestPayslip && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<DollarSign className="w-4 h-4 text-emerald-500" />
</h2>
<Link to="/portal/payslip" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{latestPayslip.month}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">¥{fmt(latestPayslip.grossPay)}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium text-emerald-600">¥{fmt(latestPayslip.netPay)}</div>
</div>
</div>
</Card>
)}
{/* 合同状态 */}
{contractInfo && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<FileText className="w-4 h-4 text-blue-500" />
</h2>
<Link to="/portal/contract" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium">{contractInfo.typeLabel || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium text-xs">{contractInfo.startDate} ~ {contractInfo.endDate || '无固定期'}</span>
</div>
{contractInfo.daysToExpire !== null && contractInfo.daysToExpire !== undefined && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className={`font-medium ${contractInfo.daysToExpire < 30 ? 'text-amber-600' : 'text-gray-700'}`}>
{contractInfo.daysToExpire > 0 ? `${contractInfo.daysToExpire}天后到期` : '已到期'}
</span>
</div>
)}
</div>
</Card>
)}
{/* 考勤概览 */}
{attendanceSummary && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<CalendarCheck className="w-4 h-4 text-purple-500" />
</h2>
<Link to="/portal/attendance" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div className="p-2 rounded-lg bg-green-50">
<div className="text-lg font-bold text-emerald-600">{attendanceSummary.normalDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-amber-50">
<div className="text-lg font-bold text-amber-600">{attendanceSummary.lateCount || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-blue-50">
<div className="text-lg font-bold text-blue-600">{attendanceSummary.leaveDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-gray-50">
<div className="text-lg font-bold text-gray-600">{attendanceSummary.absentDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
</Card>
)}
{/* 公司公告 */}
{announcements.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
<ScrollText className="w-4 h-4 text-gray-400" />
</h2>
<div className="space-y-2">
{announcements.slice(0, 3).map((ann: any, i: number) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-md hover:bg-gray-50">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700 truncate">{ann.title}</div>
<div className="text-xs text-gray-400 mt-0.5">{ann.date} · {ann.author}</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</div>
))}
</div>
</Card>
)}
</div>
)
}
@@ -0,0 +1,128 @@
/**
* 入职进度面板 — 员工端查看入职流程完成状态
* 展示入职步骤进度、材料提交状态、待完成项
*/
import { useQuery } from '@tanstack/react-query'
import { Check, Clock, AlertCircle, FileText, Upload, User, Phone, Banknote } from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
import { Stepper } from '../../components/ui/Stepper'
/** 员工端 API 实例 */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 入职步骤定义 */
const ONBOARDING_STEPS = [
{ key: 'profile', title: '基本信息', icon: User },
{ key: 'documents', title: '材料上传', icon: Upload },
{ key: 'contract', title: '合同签署', icon: FileText },
{ key: 'bankcard', title: '银行卡登记', icon: Banknote },
{ key: 'complete', title: '入职完成', icon: Check },
]
export default function OnboardingProgress() {
/** 获取入职进度数据 */
const { data: progress, isLoading } = useQuery<any>({
queryKey: ['portal-onboarding-progress'],
queryFn: async () => {
const res = await portalApi.get('/onboarding/progress') as any
return res.data
},
})
if (isLoading) {
return <div className="space-y-4">
<div className="h-24 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
}
if (!progress) {
return <Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
}
const steps = ONBOARDING_STEPS.map((s, i) => {
const stepData = progress.steps?.[s.key]
const status: 'complete' | 'current' | 'pending' =
stepData?.completed ? 'complete' :
i === progress.currentStepIndex ? 'current' : 'pending'
return { key: s.key, title: s.title, status, description: stepData?.description }
})
const completionRate = progress.completionRate || 0
const pendingItems = progress.pendingItems || []
return (
<div className="space-y-4">
{/* 进度概览 */}
<Card className="bg-gradient-to-br from-indigo-600 to-indigo-700 text-white border-0">
<div className="text-center">
<div className="text-3xl font-bold">{completionRate}%</div>
<div className="text-sm text-indigo-100 mt-1"></div>
<div className="mt-3 h-2 bg-indigo-800/50 rounded-full overflow-hidden">
<div className="h-full bg-white rounded-full transition-all" style={{ width: `${completionRate}%` }} />
</div>
</div>
</Card>
{/* 待办提醒 */}
{pendingItems.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-700 flex items-center gap-1">
<AlertCircle className="w-4 h-4 text-amber-500" />
{pendingItems.length}
</h2>
{pendingItems.map((item: any, i: number) => (
<InlineAlert key={i} type="warning" className="text-xs">
{item.message}
</InlineAlert>
))}
</div>
)}
{/* 步骤进度条 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<Stepper steps={steps} orientation="vertical" />
</Card>
{/* 各步骤详情 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="space-y-3">
{ONBOARDING_STEPS.map((step) => {
const stepData = progress.steps?.[step.key]
const Icon = step.icon
const completed = stepData?.completed
return (
<div key={step.key} className="flex items-start gap-3 p-3 rounded-lg border border-gray-100">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${
completed ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-50 text-gray-400'
}`}>
{completed ? <Check className="w-4 h-4" /> : <Icon className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">{step.title}</div>
{stepData?.description && (
<div className="text-xs text-gray-400 mt-0.5">{stepData.description}</div>
)}
{stepData?.completedAt && (
<div className="text-xs text-emerald-500 mt-0.5 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(stepData.completedAt).toLocaleDateString('zh-CN')}
</div>
)}
</div>
</div>
)
})}
</div>
</Card>
</div>
)
}
@@ -0,0 +1,202 @@
/**
* 员工离职申请入口 — 员工端提交离职申请、查看申请状态、撤回申请
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, Check, X, FileText } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 员工端 API 实例 */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 离职原因选项 */
const RESIGN_REASONS = [
{ value: '个人发展', label: '个人发展' },
{ value: '薪资待遇', label: '薪资待遇' },
{ value: '家庭原因', label: '家庭原因' },
{ value: '健康原因', label: '健康原因' },
{ value: '工作环境', label: '工作环境' },
{ value: '其他', label: '其他' },
]
/** 状态映射 */
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-400' },
}
export default function ResignationApply() {
const queryClient = useQueryClient()
const [form, setForm] = useState({
reason: '',
expectedDate: '',
remark: '',
})
/** 查询离职申请状态 */
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-resignation-status'],
queryFn: async () => {
const res = await portalApi.get('/resignation/status') as any
return res.data || []
},
})
/** 提交离职申请 */
const submitMutation = useMutation({
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
const res = await portalApi.post('/resignation/submit', data) as any
return res.data
},
onSuccess: () => {
toast.success('离职申请已提交,请等待HR审批')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
setForm({ reason: '', expectedDate: '', remark: '' })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '提交失败')
},
})
/** 撤回离职申请 */
const withdrawMutation = useMutation({
mutationFn: async (id: string) => {
const res = await portalApi.post(`/resignation/${id}/withdraw`) as any
return res.data
},
onSuccess: () => {
toast.success('离职申请已撤回')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '撤回失败')
},
})
const handleSubmit = () => {
if (!form.reason) { toast.error('请选择离职原因'); return }
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
submitMutation.mutate(form)
}
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<UserX className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<InlineAlert type="info">
HR将在3个工作日内审批30
</InlineAlert>
{/* 申请表单 */}
{!hasPending ? (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label> *</Label>
<Select value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
<option value=""></option>
{RESIGN_REASONS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</Select>
</div>
<div>
<Label> *</Label>
<Input
type="date"
value={form.expectedDate}
min={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}
onChange={(e) => setForm({ ...form, expectedDate: e.target.value })}
/>
<div className="text-xs text-gray-400 mt-1">30</div>
</div>
<div>
<Label></Label>
<Input
value={form.remark}
onChange={(e) => setForm({ ...form, remark: e.target.value })}
placeholder="补充说明(选填)"
/>
</div>
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
</Button>
</div>
</Card>
) : (
<InlineAlert type="warning">
</InlineAlert>
)}
{/* 申请记录 */}
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
<FileText className="w-4 h-4 text-gray-400" />
</h2>
{isLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : records.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{records.map((r: any) => {
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
return (
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
<span className="text-xs text-gray-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</span>
</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium">{r.terminationDate ? new Date(r.terminationDate).toLocaleDateString('zh-CN') : '—'}</span>
</div>
{r.remark && (
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
)}
</div>
{canWithdraw && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
variant="secondary"
size="sm"
onClick={() => withdrawMutation.mutate(r.id)}
disabled={withdrawMutation.isPending}
>
{withdrawMutation.isPending ? '撤回中...' : '撤回申请'}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</Card>
</div>
)
}