feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+519
View File
@@ -0,0 +1,519 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
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'
interface EmployeeItem {
id: string
name: string
department: string
hireDate: string
status: string
contractStatus: string
contractStatusText: string
riskLevel: 'high' | 'medium' | 'low' | 'safe'
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
}
interface EmployeeListResponse {
items: EmployeeItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export default function Contracts() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
const { data, isLoading } = useQuery<EmployeeListResponse>({
queryKey: ['employees', search, page],
queryFn: async () => {
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
return res.data
},
})
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/employees', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employees'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowAddModal(false)
},
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold"></h1>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 搜索栏 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="搜索员工姓名或手机号"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="pl-9"
/>
</div>
</div>
{/* 员工列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !data || data.items.length === 0 ? (
<EmptyState
title="暂无员工"
description="点击「添加员工」开始管理合同"
actionLabel="添加员工"
onAction={() => setShowAddModal(true)}
/>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
</tr>
</thead>
<tbody>
{data.items.map((emp) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedEmpId(emp.id)}>
<td className="py-3 px-3 font-medium">{emp.name}</td>
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
<td className="py-3 px-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
unsigned_over_year: 'bg-red-50 text-danger',
unsigned_over_30: 'bg-red-50 text-danger',
unsigned: 'bg-yellow-50 text-yellow-700',
expiring: 'bg-yellow-50 text-yellow-700',
active: 'bg-green-50 text-safe',
unfixed: 'bg-blue-50 text-blue-700',
}
const style = tagStyles[emp.contractStatus] || 'bg-gray-100 text-gray-600'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{emp.contractStatusText}</span>
})()}
</td>
<td className="py-3 px-3">
<div className="flex gap-1">
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600"></span>}
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600"></span>}
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600"></span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{data.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<Button
variant="secondary"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
></Button>
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
<Button
variant="secondary"
size="sm"
disabled={page === data.totalPages}
onClick={() => setPage(p => p + 1)}
></Button>
</div>
)}
</>
)}
</Card>
{/* 添加员工 Modal */}
<AddEmployeeModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={(data) => addMutation.mutate(data)}
loading={addMutation.isPending}
error={addMutation.error as any}
/>
{/* 员工详情抽屉 */}
{selectedEmpId && (
<EmployeeDetailDrawer employeeId={selectedEmpId} onClose={() => setSelectedEmpId(null)} />
)}
</div>
)
}
function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
open: boolean
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
name: '',
department: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
phone: '',
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '',
startDate: '',
endDate: '',
contractYears: 3,
probationMonths: 0,
probationSalary: 0,
})
const handleSubmit = () => {
const data: any = {
name: form.name,
department: form.department,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
phone: form.phone || undefined,
isPregnant: form.isPregnant,
isInMedicalPeriod: form.isInMedicalPeriod,
isWorkInjured: form.isWorkInjured,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType,
contractYears: form.contractYears,
probationMonths: form.probationMonths,
probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
return (
<Modal open={open} onClose={onClose} title="添加员工">
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{error.response?.data?.error?.message || '操作失败'}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" />
</div>
<div>
<Label> *</Label>
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}>
<option value="男"></option>
<option value="女"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
</div>
</div>
{/* 特殊状态 */}
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
</label>
</div>
{/* 合同信息 */}
<div className="border-t pt-3">
<Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
<option value="UNSIGNED"></option>
</Select>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
</div>
</div>
{form.contractType === 'FIXED' && (
<div className="grid grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
</div>
<div>
<Label>()</Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
</div>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.department || !form.hireDate || !form.monthlySalary}>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const { data: employee } = useQuery<any>({
queryKey: ['employee-detail', employeeId],
queryFn: async () => {
const res = await api.get(`/employees/${employeeId}`) as any
return res.data
},
})
const { data: attachments } = useQuery<any[]>({
queryKey: ['employee-attachments', employeeId],
queryFn: async () => {
const res = await api.get(`/attachments/${employeeId}`) as any
return res.data
},
})
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
}
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证',
BANK_CARD: '银行卡',
CONTRACT_SCAN: '合同扫描件',
EDUCATION: '学历证书',
OTHER: '其他',
}
const emp = employee?.data || employee
return (
<div className="fixed inset-0 z-50 flex justify-end">
<button className="fixed inset-0 bg-black/40 cursor-default" onClick={onClose} aria-label="关闭" />
<div className="relative w-full max-w-2xl bg-white h-full overflow-y-auto shadow-xl">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
<h3 className="font-medium text-gray-900"></h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-4">
{emp && (
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{emp.name}</h2>
<span className="text-xs text-gray-500">{emp.department}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-gray-400"></span>{emp.hireDate?.slice(0, 10)}</div>
<div><span className="text-gray-400"></span>{emp.gender || '-'}</div>
<div><span className="text-gray-400"></span>{emp.phone || '-'}</div>
<div><span className="text-gray-400"></span>{emp.status === 'ACTIVE' ? '在职' : '离职'}</div>
</div>
{(emp.isPregnant || emp.isInMedicalPeriod || emp.isWorkInjured) && (
<div className="flex gap-1">
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600"></span>}
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600"></span>}
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600"></span>}
</div>
)}
</div>
{emp.contracts && emp.contracts.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-xs mb-2"></h3>
<div className="space-y-2 text-xs">
{emp.contracts.map((c: any) => (
<div key={c.id} className="bg-gray-50 rounded p-2">
<div className="flex items-center gap-2">
{(() => {
const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'
const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700'
return <span className={`px-2 py-0.5 rounded text-xs ${typeStyle}`}>{typeLabel}</span>
})()}
</div>
<div className="text-gray-500 text-xs mt-1">
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
</div>
</div>
))}
</div>
</div>
)}
</>
)}
<div className="border-t pt-3">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-xs flex items-center gap-1">
<Paperclip className="w-4 h-4" />
</h3>
</div>
<div className="flex gap-2 mb-3">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="OTHER"></option>
</Select>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileUpload}
/>
<Button
size="sm"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={addAttachmentMutation.isPending}
>
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
</Button>
</div>
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
{attachments.map((att: any) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<div className="text-xs text-gray-400">
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger shrink-0 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
<div className="text-gray-400 text-xs text-center py-4"></div>
)}
</div>
</div>
</div>
</div>
)
}