Files
TurboHR/frontend/src/pages/roster/DisciplinaryRecords.tsx
T
freedakgmail b239465e78 feat: 培训记录/绩效考核/违纪记录独立列表页+菜单入口
- 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list)
- 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗
- 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口
- 路由+面包屑注册
- 社公商保改名为社保公积金
2026-08-05 08:21:53 +08:00

285 lines
13 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 TYPE_LABELS: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const SEVERITY_LABELS: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const SEVERITY_COLORS: Record<string, string> = { WARNING: 'bg-amber-50 text-amber-700', SERIOUS: 'bg-orange-50 text-orange-700', SEVERE: 'bg-red-50 text-red-700' }
const ACTION_LABELS: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
function fmtDate(d: string | Date | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
export default function DisciplinaryRecords() {
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: ['disciplinary-list', page, pageSize, keyword],
queryFn: () => rosterApi.disciplinaryList({ page, pageSize, keyword }),
})
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
delete data.employeeId
const isEdit = !!data.recordId
const recordId = data.recordId
delete data.recordId
const url = isEdit
? `/api/v1/roster/${empId}/disciplinary/${recordId}`
: `/api/v1/roster/${empId}/disciplinary`
return fetch(url, {
method: isEdit ? 'PUT' : '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: ['disciplinary-list'] })
setShowCreate(false)
setEditRecord(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
fetch(`/api/v1/roster/${employeeId}/disciplinary/${recordId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
}).then(r => r.json()),
onSuccess: () => {
toast.success('记录已删除')
queryClient.invalidateQueries({ queryKey: ['disciplinary-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>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={9} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={9} 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.violationDate)}</td>
<td className="py-2 pr-4">{TYPE_LABELS[r.violationType] || r.violationType}</td>
<td className="py-2 pr-4 max-w-xs truncate" title={r.description}>{r.description}</td>
<td className="py-2 pr-4">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${SEVERITY_COLORS[r.severity] || 'bg-gray-50 text-gray-600'}`}>
{SEVERITY_LABELS[r.severity] || r.severity}
</span>
</td>
<td className="py-2 pr-4 text-gray-600">{ACTION_LABELS[r.action] || r.action}</td>
<td className="py-2 pr-4">
{r.employeeAck ? (
<span className="text-xs text-green-600"></span>
) : (
<span className="text-xs text-gray-400"></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) && (
<DisciplinaryForm
employees={employees || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
</div>
)
}
function DisciplinaryForm({ employees, record, onSubmit, onClose }: {
employees: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
violationDate: record?.violationDate ? new Date(record.violationDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
violationType: record?.violationType || 'OTHER',
description: record?.description || '',
severity: record?.severity || 'WARNING',
action: record?.action || 'ORAL_WARNING',
actionDetail: record?.actionDetail || '',
employeeAck: record?.employeeAck || false,
witness: record?.witness || '',
})
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.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
<option value="LATE"></option>
<option value="ABSENT"></option>
<option value="INSUBORDINATION"></option>
<option value="MISCONDUCT"></option>
<option value="VIOLATE_POLICY"></option>
<option value="OTHER"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
<option value="WARNING"></option>
<option value="SERIOUS"></option>
<option value="SEVERE"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
<option value="ORAL_WARNING"></option>
<option value="WRITTEN_WARNING"></option>
<option value="DEDUCTION"></option>
<option value="DEMOTION"></option>
<option value="TERMINATION"></option>
</Select>
</div>
</div>
<div>
<Label></Label>
<Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="处理详情(选填)" />
</div>
<div>
<Label></Label>
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<span className="text-sm"></span>
</label>
<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.description}></Button>
</div>
</div>
</div>
</div>
)
}