86e5526a83
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页 - 问题2: 离职证明模板支持自定义+员工端下载 - 问题4(P0): 修复工资填写后数据归零问题 - 问题5: 社保添加员工参保信息列表 - 问题6(P0): 商业保险支持为员工参保 - 问题7(P0): 员工福利支持为员工添加福利 - 问题8: 规章制度支持导入Word文档 - 问题9: 文本模板下载Word增加HTML格式 - 问题10: 模板下载变量替换修复(排除token参数) - 问题11(P0): 电子签署发起时员工下拉框有选项 - 问题12: 新增绩效记录添加考评人选项 - 问题13: 违纪记录添加处罚执行细节 - 问题14: 特殊员工列表添加查看详情按钮和姓名链接 - 问题15: 员工福利汇总正确显示参保人员 - 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
317 lines
15 KiB
TypeScript
317 lines
15 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, Download } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import { rosterApi, employeeApi } from '../../lib/api-services'
|
||
import api from '../../lib/api'
|
||
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
|
||
if (isEdit) {
|
||
return api.put(`/roster/${empId}/disciplinary/${recordId}`, data)
|
||
}
|
||
return api.post(`/roster/${empId}/disciplinary`, data)
|
||
},
|
||
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 }) =>
|
||
api.delete(`/roster/${employeeId}/disciplinary/${recordId}`),
|
||
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>
|
||
<th className="pb-2 pr-4 font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading ? (
|
||
<tr><td colSpan={10} className="py-8 text-center text-gray-400">加载中...</td></tr>
|
||
) : records.length === 0 ? (
|
||
<tr><td colSpan={10} 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?employeeId=${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 max-w-xs truncate text-gray-500 text-xs" title={r.actionDetail}>{r.actionDetail || '-'}</td>
|
||
<td className="py-2 pr-4">
|
||
{r.employeeAck ? (
|
||
<span className="text-xs text-green-600">已签字</span>
|
||
) : (
|
||
<span className="text-xs text-amber-600">待签字</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 pr-4">
|
||
<div className="flex gap-1">
|
||
{r.employeeAck && (
|
||
<button
|
||
title="下载违纪确认证明"
|
||
onClick={async () => {
|
||
try {
|
||
const { useAuthStore } = await import('../../store/authStore')
|
||
const token = useAuthStore.getState().accessToken
|
||
const res = await fetch(`/api/v1/roster/${r.employeeId}/disciplinary/${r.id}/certificate`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
})
|
||
if (!res.ok) throw new Error('导出失败')
|
||
const blob = await res.blob()
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
const cd = res.headers.get('content-disposition') || ''
|
||
const fname = cd.match(/filename\*=UTF-8''(.+)/)?.[1] || cd.match(/filename="(.+?)"/)?.[1] || '违纪确认证明.doc'
|
||
a.download = decodeURIComponent(fname)
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
} catch { toast.error('下载失败') }
|
||
}}
|
||
className="p-1 hover:bg-gray-100 rounded"
|
||
>
|
||
<Download className="w-3.5 h-3.5 text-primary" />
|
||
</button>
|
||
)}
|
||
<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 || '',
|
||
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>
|
||
<textarea value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} rows={3} className="w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="详细说明处罚执行情况,如扣款金额、书面警告文号、降职后岗位等" />
|
||
</div>
|
||
<div>
|
||
<Label>见证人</Label>
|
||
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
|
||
</div>
|
||
{record && (
|
||
<div>
|
||
<Label>签字状态</Label>
|
||
<div className="text-sm text-gray-600">
|
||
{record.employeeAck ? (
|
||
<span className="text-green-600">已签字({record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''})</span>
|
||
) : (
|
||
<span className="text-amber-600">待签字 <span className="text-xs text-gray-400 ml-1">由员工在员工端签字确认</span></span>
|
||
)}
|
||
</div>
|
||
</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.description}>保存</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|