feat: 培训记录/绩效考核/违纪记录独立列表页+菜单入口
- 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list) - 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗 - 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口 - 路由+面包屑注册 - 社公商保改名为社保公积金
This commit is contained in:
@@ -746,6 +746,95 @@ router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 组织级列表查询 ==========
|
||||
|
||||
// 培训记录列表(全员)
|
||||
router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
const where: any = { orgId }
|
||||
if (keyword) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, name: { contains: keyword } },
|
||||
select: { id: true },
|
||||
})
|
||||
where.employeeId = { in: employees.map(e => e.id) }
|
||||
}
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.trainingRecord.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { trainingDate: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.trainingRecord.count({ where }),
|
||||
])
|
||||
res.json({ success: true, data: { records, total, page, pageSize } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 绩效记录列表(全员)
|
||||
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
const where: any = { orgId }
|
||||
if (keyword) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, name: { contains: keyword } },
|
||||
select: { id: true },
|
||||
})
|
||||
where.employeeId = { in: employees.map(e => e.id) }
|
||||
}
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.performanceRecord.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { period: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.performanceRecord.count({ where }),
|
||||
])
|
||||
res.json({ success: true, data: { records, total, page, pageSize } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 违纪记录列表(全员)
|
||||
router.get('/disciplinary/list', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const page = parseInt(req.query.page as string) || 1
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
const where: any = { orgId }
|
||||
if (keyword) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, name: { contains: keyword } },
|
||||
select: { id: true },
|
||||
})
|
||||
where.employeeId = { in: employees.map(e => e.id) }
|
||||
}
|
||||
const [records, total] = await Promise.all([
|
||||
prisma.disciplinaryRecord.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { violationDate: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.disciplinaryRecord.count({ where }),
|
||||
])
|
||||
res.json({ success: true, data: { records, total, page, pageSize } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
|
||||
@@ -45,6 +45,9 @@ const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
|
||||
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
||||
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
||||
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords'))
|
||||
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords'))
|
||||
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords'))
|
||||
|
||||
// Sprint 4-5 新增页面
|
||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
||||
@@ -200,6 +203,9 @@ export default function App() {
|
||||
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/performance-records" element={<ProtectedRoute><AdminLayout><PerformanceRecords /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/disciplinary-records" element={<ProtectedRoute><AdminLayout><DisciplinaryRecords /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/commercial-insurance" element={<ProtectedRoute><AdminLayout><CommercialInsurance /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
@@ -20,6 +20,9 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||
'/leave-approval': { group: '员工管理', label: '休假审批' },
|
||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||
'/special-status': { group: '员工管理', label: '特殊状态' },
|
||||
'/training-records': { group: '员工管理', label: '培训记录' },
|
||||
'/performance-records': { group: '员工管理', label: '绩效考核' },
|
||||
'/disciplinary-records': { group: '员工管理', label: '违纪记录' },
|
||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
||||
'/social': { group: '薪税社保', label: '社保公积金' },
|
||||
'/evidence': { group: '合规风控', label: '证据链' },
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||
Gift, PenTool, Umbrella,
|
||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import Logo from '../ui/Logo'
|
||||
import { settingsApi } from '../../lib/api-services'
|
||||
@@ -47,6 +47,9 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
|
||||
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
||||
{ path: '/disciplinary-records', label: '违纪记录', icon: AlertTriangle },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -60,7 +63,7 @@ const navGroups: NavGroup[] = [
|
||||
title: '薪酬',
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社公商保', icon: Shield },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -119,6 +119,15 @@ export const rosterApi = {
|
||||
/** 即将到期合同 */
|
||||
expiringContracts: () =>
|
||||
get('/roster/contracts/expiring').then(unwrap<any[]>()),
|
||||
/** 培训记录列表(全员) */
|
||||
trainingList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
|
||||
get('/roster/training/list', { params }).then(unwrap<any>()),
|
||||
/** 绩效记录列表(全员) */
|
||||
performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
|
||||
get('/roster/performance/list', { params }).then(unwrap<any>()),
|
||||
/** 违纪记录列表(全员) */
|
||||
disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
|
||||
get('/roster/disciplinary/list', { params }).then(unwrap<any>()),
|
||||
/** 违纪记录 */
|
||||
disciplinary: (employeeId: string) =>
|
||||
get(`/roster/${employeeId}/disciplinary`).then(unwrap<any[]>()),
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function SocialInsurance() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">社公商保</h1>
|
||||
<h1 className="text-base font-semibold">社保公积金</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">维护社保、公积金、商业保险缴费基数、版本及月度记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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 RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
const RESULT_COLORS: Record<string, string> = { EXCELLENT: 'bg-green-50 text-green-700', QUALIFIED: 'bg-blue-50 text-blue-700', NEED_IMPROVE: 'bg-amber-50 text-amber-700', UNQUALIFIED: 'bg-red-50 text-red-700' }
|
||||
|
||||
export default function PerformanceRecords() {
|
||||
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: ['performance-list', page, pageSize, keyword],
|
||||
queryFn: () => rosterApi.performanceList({ 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}/performance/${recordId}`
|
||||
: `/api/v1/roster/${empId}/performance`
|
||||
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: ['performance-list'] })
|
||||
setShowCreate(false)
|
||||
setEditRecord(null)
|
||||
},
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
|
||||
fetch(`/api/v1/roster/${employeeId}/performance/${recordId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
|
||||
}).then(r => r.json()),
|
||||
onSuccess: () => {
|
||||
toast.success('记录已删除')
|
||||
queryClient.invalidateQueries({ queryKey: ['performance-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">{r.period}</td>
|
||||
<td className="py-2 pr-4">{r.score}</td>
|
||||
<td className="py-2 pr-4">{r.grade}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs ${RESULT_COLORS[r.result] || 'bg-gray-50 text-gray-600'}`}>
|
||||
{RESULT_LABELS[r.result] || r.result}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-gray-600">{r.reviewer || '-'}</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) && (
|
||||
<PerformanceForm
|
||||
employees={employees || []}
|
||||
record={editRecord}
|
||||
onSubmit={(data) => saveMut.mutate(data)}
|
||||
onClose={() => { setShowCreate(false); setEditRecord(null) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PerformanceForm({ employees, record, onSubmit, onClose }: {
|
||||
employees: any[]
|
||||
record: any
|
||||
onSubmit: (data: any) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
employeeId: record?.employeeId || '',
|
||||
period: record?.period || new Date().toISOString().slice(0, 7),
|
||||
score: record?.score || 80,
|
||||
grade: record?.grade || 'B',
|
||||
result: record?.result || 'QUALIFIED',
|
||||
summary: record?.summary || '',
|
||||
improvementPlan: record?.improvementPlan || '',
|
||||
reviewer: record?.reviewer || '',
|
||||
employeeAck: record?.employeeAck || false,
|
||||
})
|
||||
|
||||
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="month" value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>得分</Label>
|
||||
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>等级</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option>
|
||||
<option value="B">B</option>
|
||||
<option value="C">C</option>
|
||||
<option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>考核结果</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
<option value="EXCELLENT">优秀</option>
|
||||
<option value="QUALIFIED">合格</option>
|
||||
<option value="NEED_IMPROVE">需改进</option>
|
||||
<option value="UNQUALIFIED">不胜任</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>考评人</Label>
|
||||
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="考评人姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>评语</Label>
|
||||
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} placeholder="考核评语" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>改进计划</Label>
|
||||
<Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: 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.period}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user