Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user