From b239465e783678c23ab04f8ee73c0dc377487f13 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Wed, 5 Aug 2026 08:21:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=B9=E8=AE=AD=E8=AE=B0=E5=BD=95/?= =?UTF-8?q?=E7=BB=A9=E6=95=88=E8=80=83=E6=A0=B8/=E8=BF=9D=E7=BA=AA?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=8B=AC=E7=AB=8B=E5=88=97=E8=A1=A8=E9=A1=B5?= =?UTF-8?q?+=E8=8F=9C=E5=8D=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 新增3个组织级列表接口 (training/performance/disciplinary /list) - 前端: 新增3个列表页面,支持搜索、分页、新增/编辑/删除弹窗 - 侧边栏: 团队分组新增培训记录、绩效考核、违纪记录入口 - 路由+面包屑注册 - 社公商保改名为社保公积金 --- backend/src/routes/roster.routes.ts | 89 ++++++ frontend/src/App.tsx | 6 + frontend/src/components/layout/Breadcrumb.tsx | 3 + frontend/src/components/layout/SidebarNav.tsx | 7 +- frontend/src/lib/api-services.ts | 9 + frontend/src/pages/SocialInsurance.tsx | 2 +- .../src/pages/roster/DisciplinaryRecords.tsx | 284 ++++++++++++++++++ .../src/pages/roster/PerformanceRecords.tsx | 258 ++++++++++++++++ frontend/src/pages/roster/TrainingRecords.tsx | 275 +++++++++++++++++ 9 files changed, 930 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/roster/DisciplinaryRecords.tsx create mode 100644 frontend/src/pages/roster/PerformanceRecords.tsx create mode 100644 frontend/src/pages/roster/TrainingRecords.tsx diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 565dea8..11a1570 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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) => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ebd8e2..0a0f156 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx index 10b3a06..cf45252 100644 --- a/frontend/src/components/layout/Breadcrumb.tsx +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -20,6 +20,9 @@ const ROUTE_MAP: Record = { '/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: '证据链' }, diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 71dce94..168afe3 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -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 }, ], }, diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index cb02e4a..2169f55 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -119,6 +119,15 @@ export const rosterApi = { /** 即将到期合同 */ expiringContracts: () => get('/roster/contracts/expiring').then(unwrap()), + /** 培训记录列表(全员) */ + trainingList: (params: { page?: number; pageSize?: number; keyword?: string }) => + get('/roster/training/list', { params }).then(unwrap()), + /** 绩效记录列表(全员) */ + performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) => + get('/roster/performance/list', { params }).then(unwrap()), + /** 违纪记录列表(全员) */ + disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) => + get('/roster/disciplinary/list', { params }).then(unwrap()), /** 违纪记录 */ disciplinary: (employeeId: string) => get(`/roster/${employeeId}/disciplinary`).then(unwrap()), diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index f993170..d634297 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -345,7 +345,7 @@ export default function SocialInsurance() {
-

社公商保

+

社保公积金

维护社保、公积金、商业保险缴费基数、版本及月度记录

diff --git a/frontend/src/pages/roster/DisciplinaryRecords.tsx b/frontend/src/pages/roster/DisciplinaryRecords.tsx new file mode 100644 index 0000000..776be33 --- /dev/null +++ b/frontend/src/pages/roster/DisciplinaryRecords.tsx @@ -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 = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } +const SEVERITY_LABELS: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' } +const SEVERITY_COLORS: Record = { 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 = { 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(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 ( +
+
+
+

违纪记录

+

管理全员违纪记录及处理情况

+
+ +
+ +
+
+ + { setKeyword(e.target.value); setPage(1) }} + placeholder="搜索员工姓名" + className="pl-9" + /> +
+
+ +
+ + + + + + + + + + + + + + + + {isLoading ? ( + + ) : records.length === 0 ? ( + + ) : records.map((r: any) => ( + + + + + + + + + + + + ))} + +
员工部门违纪日期类型描述严重程度处理签字操作
加载中...
暂无违纪记录
+ {r.employee?.name} + {r.employee?.department || '-'}{fmtDate(r.violationDate)}{TYPE_LABELS[r.violationType] || r.violationType}{r.description} + + {SEVERITY_LABELS[r.severity] || r.severity} + + {ACTION_LABELS[r.action] || r.action} + {r.employeeAck ? ( + 已签字 + ) : ( + 未签字 + )} + +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {page} / {totalPages} + +
+
+ )} + + {(showCreate || editRecord) && ( + saveMut.mutate(data)} + onClose={() => { setShowCreate(false); setEditRecord(null) }} + /> + )} +
+ ) +} + +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 ( +
+
e.stopPropagation()}> +
+

{record ? '编辑违纪记录' : '新增违纪记录'}

+ +
+
+ {!record && ( +
+ + +
+ )} +
+ + setForm({ ...form, violationDate: e.target.value })} /> +
+
+ + +
+
+ + setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" /> +
+
+
+ + +
+
+ + +
+
+
+ + setForm({ ...form, actionDetail: e.target.value })} placeholder="处理详情(选填)" /> +
+
+ + setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/roster/PerformanceRecords.tsx b/frontend/src/pages/roster/PerformanceRecords.tsx new file mode 100644 index 0000000..0fb0b95 --- /dev/null +++ b/frontend/src/pages/roster/PerformanceRecords.tsx @@ -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 = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' } +const RESULT_COLORS: Record = { 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(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 ( +
+
+
+

绩效考核

+

管理全员绩效考核记录

+
+ +
+ +
+
+ + { setKeyword(e.target.value); setPage(1) }} + placeholder="搜索员工姓名" + className="pl-9" + /> +
+
+ +
+ + + + + + + + + + + + + + + {isLoading ? ( + + ) : records.length === 0 ? ( + + ) : records.map((r: any) => ( + + + + + + + + + + + ))} + +
员工部门考核周期得分等级结果考评人操作
加载中...
暂无绩效记录
+ {r.employee?.name} + {r.employee?.department || '-'}{r.period}{r.score}{r.grade} + + {RESULT_LABELS[r.result] || r.result} + + {r.reviewer || '-'} +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {page} / {totalPages} + +
+
+ )} + + {(showCreate || editRecord) && ( + saveMut.mutate(data)} + onClose={() => { setShowCreate(false); setEditRecord(null) }} + /> + )} +
+ ) +} + +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 ( +
+
e.stopPropagation()}> +
+

{record ? '编辑绩效记录' : '新增绩效记录'}

+ +
+
+ {!record && ( +
+ + +
+ )} +
+ + setForm({ ...form, period: e.target.value })} /> +
+
+
+ + setForm({ ...form, score: Number(e.target.value) })} /> +
+
+ + +
+
+
+ + +
+
+ + setForm({ ...form, reviewer: e.target.value })} placeholder="考评人姓名" /> +
+
+ + setForm({ ...form, summary: e.target.value })} placeholder="考核评语" /> +
+
+ + setForm({ ...form, improvementPlan: e.target.value })} placeholder="改进计划(选填)" /> +
+
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/roster/TrainingRecords.tsx b/frontend/src/pages/roster/TrainingRecords.tsx new file mode 100644 index 0000000..4c85d9d --- /dev/null +++ b/frontend/src/pages/roster/TrainingRecords.tsx @@ -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 = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' } +const ACK_COLORS: Record = { 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(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 ( +
+
+
+

培训记录

+

管理全员培训记录及签收状态

+
+ +
+ +
+
+ + { setKeyword(e.target.value); setPage(1) }} + placeholder="搜索员工姓名" + className="pl-9" + /> +
+
+ +
+ + + + + + + + + + + + + + + {isLoading ? ( + + ) : records.length === 0 ? ( + + ) : records.map((r: any) => ( + + + + + + + + + + + ))} + +
员工部门培训日期主题讲师时长(小时)签收状态操作
加载中...
暂无培训记录
+ {r.employee?.name} + {r.employee?.department || '-'}{fmtDate(r.trainingDate)}{r.topic}{r.trainer || '-'}{r.duration} + + {ACK_LABELS[r.ackStatus] || r.ackStatus} + + +
+ + +
+
+
+ + {totalPages > 1 && ( +
+ 共 {total} 条 +
+ + {page} / {totalPages} + +
+
+ )} + + {(showCreate || editRecord) && ( + { + if (editRecord) { + updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id }) + } else { + createMut.mutate(data) + } + }} + onClose={() => { setShowCreate(false); setEditRecord(null) }} + /> + )} +
+ ) +} + +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 ( +
+
e.stopPropagation()}> +
+

{record ? '编辑培训记录' : '新增培训记录'}

+ +
+
+ {!record && ( +
+ + +
+ )} +
+ + setForm({ ...form, trainingDate: e.target.value })} /> +
+
+ + setForm({ ...form, topic: e.target.value })} placeholder="培训主题" /> +
+
+ + setForm({ ...form, content: e.target.value })} placeholder="培训内容" /> +
+
+
+ + setForm({ ...form, trainer: e.target.value })} placeholder="讲师姓名" /> +
+
+ + setForm({ ...form, duration: Number(e.target.value) })} /> +
+
+
+ + +
+
+ + setForm({ ...form, remark: e.target.value })} placeholder="备注" /> +
+
+ + +
+
+
+
+ ) +}