b239465e78
- 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list) - 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗 - 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口 - 路由+面包屑注册 - 社公商保改名为社保公积金
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Link } from 'react-router-dom'
|
|
import { Search, Plus, Edit2, Trash2, X } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { rosterApi, employeeApi } from '../../lib/api-services'
|
|
import { usePageSize } from '../../hooks/usePageSize'
|
|
import { Input, Label, Select } from '../../components/ui/Input'
|
|
import Button from '../../components/ui/Button'
|
|
|
|
const ACK_LABELS: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
|
const ACK_COLORS: Record<string, string> = { PENDING: 'bg-amber-50 text-amber-700', SIGNED: 'bg-green-50 text-green-700', REFUSED: 'bg-red-50 text-red-700' }
|
|
|
|
function fmtDate(d: string | Date | null): string {
|
|
if (!d) return '-'
|
|
return new Date(d).toLocaleDateString('zh-CN')
|
|
}
|
|
|
|
export default function TrainingRecords() {
|
|
const queryClient = useQueryClient()
|
|
const pageSize = usePageSize()
|
|
const [page, setPage] = useState(1)
|
|
const [keyword, setKeyword] = useState('')
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editRecord, setEditRecord] = useState<any>(null)
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['training-list', page, pageSize, keyword],
|
|
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword }),
|
|
})
|
|
|
|
const { data: employees } = useQuery({
|
|
queryKey: ['employees-active'],
|
|
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
|
})
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: any) => {
|
|
const empId = data.employeeId
|
|
delete data.employeeId
|
|
return fetch(`/api/v1/roster/${empId}/training`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
|
body: JSON.stringify(data),
|
|
}).then(r => r.json())
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('培训记录已添加')
|
|
queryClient.invalidateQueries({ queryKey: ['training-list'] })
|
|
setShowCreate(false)
|
|
},
|
|
onError: () => toast.error('添加失败'),
|
|
})
|
|
|
|
const updateMut = useMutation({
|
|
mutationFn: (data: any) => {
|
|
const empId = data.employeeId
|
|
const recordId = data.recordId
|
|
delete data.employeeId
|
|
delete data.recordId
|
|
return fetch(`/api/v1/roster/${empId}/training/${recordId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
|
body: JSON.stringify(data),
|
|
}).then(r => r.json())
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('培训记录已更新')
|
|
queryClient.invalidateQueries({ queryKey: ['training-list'] })
|
|
setEditRecord(null)
|
|
},
|
|
onError: () => toast.error('更新失败'),
|
|
})
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
|
|
fetch(`/api/v1/roster/${employeeId}/training/${recordId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
|
}).then(r => r.json()),
|
|
onSuccess: () => {
|
|
toast.success('记录已删除')
|
|
queryClient.invalidateQueries({ queryKey: ['training-list'] })
|
|
},
|
|
})
|
|
|
|
const records = data?.records || []
|
|
const total = data?.total || 0
|
|
const totalPages = Math.ceil(total / pageSize)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-base font-semibold">培训记录</h1>
|
|
<p className="mt-1 text-sm text-gray-500">管理全员培训记录及签收状态</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => setShowCreate(true)}>
|
|
<Plus className="w-4 h-4 mr-1" /> 新增培训记录
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<Input
|
|
value={keyword}
|
|
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
|
|
placeholder="搜索员工姓名"
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-xs text-gray-500">
|
|
<th className="pb-2 pr-4 font-medium">员工</th>
|
|
<th className="pb-2 pr-4 font-medium">部门</th>
|
|
<th className="pb-2 pr-4 font-medium">培训日期</th>
|
|
<th className="pb-2 pr-4 font-medium">主题</th>
|
|
<th className="pb-2 pr-4 font-medium">讲师</th>
|
|
<th className="pb-2 pr-4 font-medium">时长(小时)</th>
|
|
<th className="pb-2 pr-4 font-medium">签收状态</th>
|
|
<th className="pb-2 pr-4 font-medium">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<tr><td colSpan={8} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
|
) : records.length === 0 ? (
|
|
<tr><td colSpan={8} className="py-8 text-center text-gray-400">暂无培训记录</td></tr>
|
|
) : records.map((r: any) => (
|
|
<tr key={r.id} className="border-b hover:bg-gray-50">
|
|
<td className="py-2 pr-4">
|
|
<Link to={`/roster/${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
|
|
</td>
|
|
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
|
|
<td className="py-2 pr-4">{fmtDate(r.trainingDate)}</td>
|
|
<td className="py-2 pr-4">{r.topic}</td>
|
|
<td className="py-2 pr-4 text-gray-600">{r.trainer || '-'}</td>
|
|
<td className="py-2 pr-4">{r.duration}</td>
|
|
<td className="py-2 pr-4">
|
|
<span className={`inline-block px-2 py-0.5 rounded text-xs ${ACK_COLORS[r.ackStatus] || 'bg-gray-50 text-gray-600'}`}>
|
|
{ACK_LABELS[r.ackStatus] || r.ackStatus}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 pr-4">
|
|
<div className="flex gap-1">
|
|
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
|
|
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
|
|
</button>
|
|
<button
|
|
onClick={() => { if (confirm('确认删除?')) deleteMut.mutate({ employeeId: r.employeeId, recordId: r.id }) }}
|
|
className="p-1 hover:bg-gray-100 rounded"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 text-red-400" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-gray-500">共 {total} 条</span>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>上一页</Button>
|
|
<span className="px-3 py-1 text-xs text-gray-500">{page} / {totalPages}</span>
|
|
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>下一页</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(showCreate || editRecord) && (
|
|
<TrainingForm
|
|
employees={employees || []}
|
|
record={editRecord}
|
|
onSubmit={(data) => {
|
|
if (editRecord) {
|
|
updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id })
|
|
} else {
|
|
createMut.mutate(data)
|
|
}
|
|
}}
|
|
onClose={() => { setShowCreate(false); setEditRecord(null) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TrainingForm({ employees, record, onSubmit, onClose }: {
|
|
employees: any[]
|
|
record: any
|
|
onSubmit: (data: any) => void
|
|
onClose: () => void
|
|
}) {
|
|
const [form, setForm] = useState({
|
|
employeeId: record?.employeeId || '',
|
|
trainingDate: record?.trainingDate ? new Date(record.trainingDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
|
|
topic: record?.topic || '',
|
|
content: record?.content || '',
|
|
trainer: record?.trainer || '',
|
|
duration: record?.duration || 0,
|
|
ackStatus: record?.ackStatus || 'PENDING',
|
|
remark: record?.remark || '',
|
|
})
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-medium">{record ? '编辑培训记录' : '新增培训记录'}</h3>
|
|
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{!record && (
|
|
<div>
|
|
<Label>员工</Label>
|
|
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
|
|
<option value="">请选择员工</option>
|
|
{employees.map((emp: any) => (
|
|
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<Label>培训日期</Label>
|
|
<Input type="date" value={form.trainingDate} onChange={(e) => setForm({ ...form, trainingDate: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label>培训主题</Label>
|
|
<Input value={form.topic} onChange={(e) => setForm({ ...form, topic: e.target.value })} placeholder="培训主题" />
|
|
</div>
|
|
<div>
|
|
<Label>培训内容</Label>
|
|
<Input value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} placeholder="培训内容" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label>讲师</Label>
|
|
<Input value={form.trainer} onChange={(e) => setForm({ ...form, trainer: e.target.value })} placeholder="讲师姓名" />
|
|
</div>
|
|
<div>
|
|
<Label>时长(小时)</Label>
|
|
<Input type="number" min={0} step={0.5} value={form.duration} onChange={(e) => setForm({ ...form, duration: Number(e.target.value) })} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>签收状态</Label>
|
|
<Select value={form.ackStatus} onChange={(e) => setForm({ ...form, ackStatus: e.target.value })}>
|
|
<option value="PENDING">待签收</option>
|
|
<option value="SIGNED">已签收</option>
|
|
<option value="REFUSED">拒绝签收</option>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>备注</Label>
|
|
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="备注" />
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
|
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.topic}>保存</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|