feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)

P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
freedakgmail
2026-08-09 11:59:02 +08:00
parent c355a7d208
commit a2e9ba55c2
43 changed files with 2913 additions and 324 deletions
+3 -2
View File
@@ -9,9 +9,10 @@ interface ModalProps {
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
closeOnOverlayClick?: boolean
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
export default function Modal({ open, onClose, title, children, className, size = 'md', closeOnOverlayClick = true }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
@@ -33,7 +34,7 @@ export default function Modal({ open, onClose, title, children, className, size
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
onClick={closeOnOverlayClick ? onClose : undefined}
/>
<div
className={clsx(
+2 -1
View File
@@ -1,5 +1,6 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { setPageSize } from '../../lib/pageSize'
interface PaginationProps {
page: number // 当前页(1-based
@@ -45,7 +46,7 @@ export default function Pagination({
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
onChange={(e) => { setPageSize(Number(e.target.value)); onPageSizeChange?.(Number(e.target.value)) }}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
+34 -1
View File
@@ -66,6 +66,9 @@ export const employeeApi = {
/** 创建员工 */
create: (data: Record<string, unknown>) =>
post('/employees', data),
/** 身份证查重 */
checkIdCard: (idCard: string) =>
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
/** 更新员工 */
update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data),
@@ -120,11 +123,26 @@ export const rosterApi = {
expiringContracts: () =>
get('/roster/contracts/expiring').then(unwrap<any[]>()),
/** 培训记录列表(全员) */
trainingList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
trainingList: (params: { page?: number; pageSize?: number; keyword?: string; ackStatus?: string }) =>
get('/roster/training/list', { params }).then(unwrap<any>()),
/** 培训记录催办 */
trainingRemind: (recordId: string) =>
post(`/roster/training/remind/${recordId}`).then(unwrap<any>()),
/** 绩效记录列表(全员) */
performanceList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/performance/list', { params }).then(unwrap<any>()),
/** 绩效模板列表 */
performanceTemplates: () =>
get('/roster/performance/templates').then(unwrap<any[]>()),
/** 创建绩效模板 */
createPerformanceTemplate: (data: any) =>
post('/roster/performance/templates', data).then(unwrap<any>()),
/** 更新绩效模板 */
updatePerformanceTemplate: (id: string, data: any) =>
put(`/roster/performance/templates/${id}`, data).then(unwrap<any>()),
/** 删除绩效模板 */
deletePerformanceTemplate: (id: string) =>
del(`/roster/performance/templates/${id}`).then(unwrap<any>()),
/** 违纪记录列表(全员) */
disciplinaryList: (params: { page?: number; pageSize?: number; keyword?: string }) =>
get('/roster/disciplinary/list', { params }).then(unwrap<any>()),
@@ -447,6 +465,9 @@ export const payrollApi = {
/** 批量导入加班工时 */
batchImportOvertime: (data: Record<string, unknown>[]) =>
post('/payroll/overtime/batch', data),
/** 从考勤记录同步加班工时 */
syncOvertimeFromAttendance: (month: string) =>
post('/payroll/overtime/sync-from-attendance', { month }).then(unwrap<any>()),
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
@@ -655,6 +676,9 @@ export const terminationApi = {
/** 撤销 */
cancel: (draftId: string) =>
post(`/termination/draft/${draftId}/cancel`),
/** 删除草稿(仅 DRAFT 和 CANCELLED 状态) */
deleteDraft: (draftId: string) =>
del(`/termination/draft/${draftId}`),
/** 撤回离职记录 */
revoke: (recordId: string) =>
del(`/termination/${recordId}/revoke`),
@@ -693,6 +717,9 @@ export const policiesApi = {
/** 阅读签收统计 */
readStats: (id: string) =>
get(`/policies/${id}/read-stats`).then(unwrap<any>()),
/** 催办未签收员工 */
remind: (id: string, employeeIds?: string[]) =>
post(`/policies/${id}/remind`, { employeeIds }).then(unwrap<any>()),
}
// ========== 证据链相关 ==========
@@ -704,6 +731,12 @@ export const evidenceApi = {
/** 全量验证 */
verifyAll: () =>
get('/evidence/verify-all').then(unwrap<any>()),
/** 按员工获取证据链记录 */
byEmployee: (employeeId: string) =>
get(`/evidence/employee/${employeeId}`).then(unwrap<any[]>()),
/** 验证单条证据链 */
verify: (id: string) =>
get(`/evidence/verify/${id}`).then(unwrap<any>()),
}
// ========== 审计日志 ==========
+19
View File
@@ -0,0 +1,19 @@
import { toast } from 'sonner'
/**
* 从 axios 错误中提取并显示错误信息,支持 Zod 校验失败时展示具体字段
*/
export function toastError(err: any, fallback = '操作失败') {
const error = err?.response?.data?.error
if (!error) {
toast.error(fallback)
return
}
// 如果有 details(Zod 校验失败),展示具体字段
if (error.details && Array.isArray(error.details) && error.details.length > 0) {
const fields = error.details.map((d: any) => `${d.path || '字段'}: ${d.message}`).join('')
toast.error(`${error.message}${fields}`)
return
}
toast.error(error.message || fallback)
}
+21 -9
View File
@@ -83,7 +83,7 @@ export default function Attendance() {
})}
</div>
{activeTab === 'confirm' && <ConfirmTab />}
{activeTab === 'confirm' && <ConfirmTab onGoToTab={setActiveTab} />}
{activeTab === 'shifts' && <ShiftsTab />}
{activeTab === 'schedule' && <ScheduleTab />}
{activeTab === 'daily' && <DailyTab />}
@@ -94,7 +94,7 @@ export default function Attendance() {
}
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -538,14 +538,14 @@ function ConfirmTab() {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/template`, {
const res = await fetch(`${baseURL}/import/monthly-template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '员工导入模板.xlsx'
a.download = '考勤月度导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
@@ -555,7 +555,7 @@ function ConfirmTab() {
</div>
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
Sheet
Sheet 0
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
@@ -570,6 +570,10 @@ function ConfirmTab() {
<div className="font-medium"></div>
{importResult.attendance > 0 && <div>{importResult.attendance} </div>}
{importResult.overtime > 0 && <div>{importResult.overtime} </div>}
{importResult.discipline > 0 && <div>{importResult.discipline} </div>}
{importResult.salaryChanges > 0 && <div>{importResult.salaryChanges} </div>}
{importResult.socialInsChanges > 0 && <div>{importResult.socialInsChanges} </div>}
{importResult.housingFundChanges > 0 && <div>{importResult.housingFundChanges} </div>}
{importResult.employees > 0 && <div>{importResult.employees} </div>}
{importResult.contracts > 0 && <div>{importResult.contracts} </div>}
{importResult.skipped > 0 && <div className="text-amber-600"> {importResult.skipped} </div>}
@@ -579,6 +583,12 @@ function ConfirmTab() {
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
<button
className="mt-1 text-primary hover:underline font-medium"
onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null); onGoToTab?.('confirm') }}
>
</button>
</div>
)}
@@ -1094,8 +1104,8 @@ function DailyTab() {
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-xs text-gray-500">{emp.shift ? `${emp.shift.name}` : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime || '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkInTime ? (() => { const d = new Date(emp.checkInTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3 text-xs font-mono">{emp.checkOutTime ? (() => { const d = new Date(emp.checkOutTime); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}`; })() : '—'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[emp.status] || 'bg-gray-100 text-gray-500'}`}>
{ATTENDANCE_STATUS[emp.status] || emp.status}
@@ -1107,9 +1117,10 @@ function DailyTab() {
className="text-xs text-primary hover:underline"
onClick={() => {
setEditEmp(emp)
const fmtTime = (t: string) => { if (!t) return ''; const d = new Date(t); return `${String(d.getUTCHours()).padStart(2,'0')}:${String(d.getUTCMinutes()).padStart(2,'0')}` }
setEditForm({
checkInTime: emp.checkInTime || '',
checkOutTime: emp.checkOutTime || '',
checkInTime: fmtTime(emp.checkInTime),
checkOutTime: fmtTime(emp.checkOutTime),
status: emp.status || 'NORMAL',
remark: '',
})
@@ -1156,6 +1167,7 @@ function DailyTab() {
<option value="ABSENT"></option>
<option value="LEAVE"></option>
<option value="BUSINESS_TRIP"></option>
<option value="UNREGISTERED"></option>
</Select>
</div>
<div className="col-span-2">
+12 -4
View File
@@ -61,6 +61,7 @@ export default function Contracts() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
},
})
@@ -219,6 +220,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const [form, setForm] = useState({
name: '',
department: '',
position: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
@@ -239,6 +241,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
const data: any = {
name: form.name,
department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
@@ -282,14 +285,19 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>/</Label>
<Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
</div>
<div>
<Label> *</Label>
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
</div>
<div className="grid grid-cols-2 gap-3">
+7 -7
View File
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react'
import { benefitApi, rosterApi } from '../lib/api-services'
import { benefitApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -72,10 +72,10 @@ export default function EmployeeBenefits() {
enabled: tab === 'summary',
})
const { data: rosterData } = useQuery<any>({
queryKey: ['roster-for-benefit', ''],
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-benefit'],
queryFn: async () => {
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
@@ -407,10 +407,10 @@ export default function EmployeeBenefits() {
<thead className="sticky top-0 bg-white">
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').length || 0) && enrollEmployeeIds.length > 0}
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.items?.filter((emp: any) => emp.status === 'ACTIVE').map((emp: any) => emp.id) || [])
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
@@ -422,7 +422,7 @@ export default function EmployeeBenefits() {
</tr>
</thead>
<tbody>
{rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => (
{rosterData?.map((emp: any) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
+13
View File
@@ -71,6 +71,19 @@ export default function Evidence() {
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
</span>
</div>
{verifyResult?.invalidItems?.length > 0 && (
<div className="mt-3 space-y-1.5">
{verifyResult.invalidItems.map((item: any) => (
<div key={item.id} className="flex items-center justify-between px-3 py-2 rounded bg-red-50 border border-red-200 text-sm">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-red-700">{item.description}</span>
</div>
<span className="text-xs text-gray-400">{new Date(item.createdAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
)}
</Card>
)}
+5 -2
View File
@@ -1,4 +1,5 @@
import { useState, lazy, Suspense } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
@@ -9,7 +10,9 @@ const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ defa
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('batch')
const [searchParams] = useSearchParams()
const initialEmployeeId = searchParams.get('employeeId') || ''
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch')
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
@@ -47,7 +50,7 @@ export default function Money() {
{tab === 'batch' && <BatchManager />}
{tab === 'template' && <TemplateManager />}
{tab === 'overtime' && <OvertimeCalculator />}
{tab === 'payslip' && <PayslipManager />}
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
</Suspense>
</div>
)
+33 -3
View File
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react'
import { policiesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -247,6 +247,8 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
* 阅读签收统计组件
*/
function ReadStats({ policyId }: { policyId: string }) {
const queryClient = useQueryClient()
const [showUnread, setShowUnread] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
@@ -254,6 +256,17 @@ function ReadStats({ policyId }: { policyId: string }) {
},
})
const remindMutation = useMutation({
mutationFn: async (employeeIds?: string[]) => {
return await policiesApi.remind(policyId, employeeIds)
},
onSuccess: (res: any) => {
toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`)
queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] })
},
onError: () => toast.error('催办失败'),
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
@@ -271,8 +284,25 @@ function ReadStats({ policyId }: { policyId: string }) {
</span>
</div>
{data.unreadCount > 0 && (
<div className="text-xs text-amber-600 mb-2">
{data.unreadCount}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-amber-600">{data.unreadCount} </span>
<button onClick={() => setShowUnread(!showUnread)} className="text-xs text-primary hover:underline">
{showUnread ? '收起' : '查看明细'}
</button>
<Button size="sm" variant="secondary" className="!h-6 !px-2 !text-xs" onClick={() => remindMutation.mutate(undefined)} disabled={remindMutation.isPending}>
<Bell className="w-3 h-3 mr-1" />
</Button>
</div>
)}
{showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1 mb-2">
{data.unreadEmployees.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-amber-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-amber-600"></span>
</div>
))}
</div>
)}
{data.records && data.records.length > 0 && (
+34 -1
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
@@ -33,6 +34,8 @@ const ROSTER_COLUMNS = [
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'socialInsBase', label: '社保基数' },
{ key: 'socialInsAmount', label: '社保缴费' },
{ key: 'records', label: '记录' },
] as const
@@ -127,8 +130,13 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
toast.success('员工已添加', {
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
})
},
onError: (err: any) => toastError(err, '创建失败'),
})
const resignMutation = useMutation({
@@ -373,6 +381,9 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
<UserPlus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
@@ -504,6 +515,8 @@ export default function Roster() {
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialInsBase') && <th className="px-4 py-3 text-right"></th>}
{colVisible('socialInsAmount') && <th className="px-4 py-3 text-right"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
@@ -636,6 +649,26 @@ export default function Roster() {
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs">
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300"></span>}
</td>}
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs">
{(() => {
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300"></span>
const socialEmp = e.socialInsCalc?.socialEmp || 0
const socialOrg = e.socialInsCalc?.socialOrg || 0
const housingEmp = e.housingFundCalc?.housingEmp || 0
const housingOrg = e.housingFundCalc?.housingOrg || 0
const totalEmp = socialEmp + housingEmp
const totalOrg = socialOrg + housingOrg
return (
<div className="flex flex-col">
<span>: {totalEmp.toFixed(2)}</span>
<span className="text-gray-400">: {totalOrg.toFixed(2)}</span>
</div>
)
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
@@ -761,7 +794,7 @@ export default function Roster() {
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
pageSize={pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
+29 -2
View File
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import mammoth from 'mammoth'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
@@ -303,6 +304,7 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const fileInputRef = useRef<HTMLInputElement>(null)
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
@@ -490,6 +492,31 @@ function EnterpriseTemplates() {
</div>
<div>
<Label></Label>
<div className="flex items-center gap-2 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setForm({ ...form, content: result.value })
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[200px] font-mono"
value={form.content}
+90 -36
View File
@@ -205,6 +205,8 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
const draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
@@ -433,6 +435,16 @@ export default function Termination() {
onError: () => toast.error('撤销失败'),
})
// 删除草稿
const deleteDraftMutation = useMutation({
mutationFn: (id: string) => terminationApi.deleteDraft(id),
onSuccess: () => {
toast.success('草稿已删除')
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
},
onError: () => toast.error('删除失败'),
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -728,8 +740,23 @@ export default function Termination() {
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
{(searchTerm || filterStatus || filterDepartment) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment('') }} className="text-xs text-gray-500 hover:text-primary"></button>
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="开始日期"
/>
<span className="text-xs text-gray-400"></span>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
placeholder="结束日期"
/>
{(searchTerm || filterStatus || filterDepartment || filterDateFrom || filterDateTo) && (
<button onClick={() => { setSearchTerm(''); setFilterStatus(''); setFilterDepartment(''); setFilterDateFrom(''); setFilterDateTo('') }} className="text-xs text-gray-500 hover:text-primary"></button>
)}
<Button variant="secondary" size="sm" onClick={async () => {
try {
@@ -737,6 +764,8 @@ export default function Termination() {
if (searchTerm) params.set('search', searchTerm)
if (filterStatus) params.set('status', filterStatus)
if (filterDepartment) params.set('department', filterDepartment)
if (filterDateFrom) params.set('dateFrom', filterDateFrom)
if (filterDateTo) params.set('dateTo', filterDateTo)
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/export/terminations?${params}`, { headers: { Authorization: `Bearer ${token}` } })
@@ -866,6 +895,20 @@ export default function Termination() {
<Ban className="w-3.5 h-3.5" />
</button>
)}
{(item.status === 'DRAFT' || item.status === 'CANCELLED') && (
<button
onClick={async () => {
if (await confirm({ title: '删除草稿', message: '确定删除此草稿记录?删除后不可恢复。' })) {
deleteDraftMutation.mutate(item.id)
}
}}
className="p-1 text-gray-400 hover:text-danger"
aria-label="删除"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleViewDetail(item.id)}
className="p-1 text-gray-500 hover:text-primary"
@@ -1025,21 +1068,29 @@ export default function Termination() {
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(18)
doc.text('解除/终止劳动合同证明书', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 45
doc.text(`兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),`, 14, y); y += 8
doc.text(`原系我公司 ${draftDetail.department} 部门员工,`, 14, y); y += 8
doc.text(`${draftDetail.terminationDate}${REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason} 原因,`, 14, y); y += 8
doc.text(`正式解除/终止劳动合同。`, 14, y); y += 8
doc.text(`经济补偿金已结清:¥${fmt(draftDetail.compensation)}`, 14, y); y += 8
doc.text(`社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}`, 14, y); y += 16
doc.text('特此证明。', 14, y); y += 24
doc.text('公司(盖章)', 140, y)
doc.text(new Date().toISOString().slice(0, 10), 140, y + 8)
doc.save(`离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
const reasonLabel = REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>离职证明</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">解除/终止劳动合同证明书</div>
<div class="body">兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),原系我公司 ${draftDetail.department || '—'} 部门员工,于 ${draftDetail.terminationDate}${reasonLabel} 原因,正式解除/终止劳动合同。</div>
<div class="body">经济补偿金已结清:¥${fmt(draftDetail.compensation)}。社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}。</div>
<div class="body">特此证明。</div>
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
@@ -1048,25 +1099,28 @@ export default function Termination() {
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('工作交接清单', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 40
doc.text(`员工姓名:${draftDetail.employeeName}`, 14, y); y += 8
doc.text(`部门:${draftDetail.department || '—'}`, 14, y); y += 8
doc.text(`离职日期:${draftDetail.terminationDate}`, 14, y); y += 12
doc.setFontSize(10)
draftDetail.handoverItems.forEach((item: any, i: number) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(`${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}`, 14, y); y += 7
})
y += 16
doc.text('交接人签字:____________', 14, y)
doc.text('接收人签字:____________', 100, y)
y += 12
doc.text('日期:____________', 14, y)
doc.save(`交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
const items = draftDetail.handoverItems.map((item: any) => `<div class="body">${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}</div>`).join('')
const html = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head><meta charset="utf-8"><title>工作交接清单</title>
<style>
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
.title { font-size: 18pt; font-weight: bold; margin-bottom: 20pt; }
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
</style></head>
<body>
<div class="title">工作交接清单</div>
<div class="body">员工姓名:${draftDetail.employeeName}  部门:${draftDetail.department || '—'}  离职日期:${draftDetail.terminationDate}</div>
${items}
<div class="sign">交接人签字:____________<br/>接收人签字:____________<br/>日期:____________</div>
</body></html>`
const blob = new Blob(['\ufeff' + html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.doc`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-4 h-4 mr-1" />
+80 -38
View File
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { toastError } from '../lib/errorToast'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Repeat, Pause, FileText, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
@@ -21,7 +23,6 @@ const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
TERMINATE: XCircle, RESCIND: UserX, LEAVING_CERT: FileMinus,
FLEXIBLE: Briefcase,
}
@@ -35,9 +36,6 @@ const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
RENEW: { label: '合同续签', description: '到期合同续签' },
SUSPEND: { label: '合同中止', description: '中止履行合同' },
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
TERMINATE: { label: '合同终止', description: '合同到期终止' },
RESCIND: { label: '合同解除', description: '协商或单方解除合同' },
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明' },
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
}
@@ -106,28 +104,16 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
@@ -155,8 +141,15 @@ export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
const [formData, setFormData] = useState<Record<string, any>>({})
const [selectedType, setSelectedType] = useState<string>(() => {
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
})
const [formData, setFormData] = useState<Record<string, any>>(() => {
try {
const saved = localStorage.getItem('workprocess-draft-data')
return saved ? JSON.parse(saved) : {}
} catch { return {} }
})
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
@@ -168,6 +161,22 @@ export default function WorkProcess() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
if (selectedType && Object.keys(formData).length > 0) {
localStorage.setItem('workprocess-draft-type', selectedType)
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
} else {
localStorage.removeItem('workprocess-draft-type')
localStorage.removeItem('workprocess-draft-data')
}
} catch {}
}, [selectedType, formData])
const isDirty = selectedType && Object.keys(formData).length > 0
useUnsavedChanges(!!isDirty)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
@@ -191,7 +200,7 @@ export default function WorkProcess() {
setFormData({})
setSelectedType('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
onError: (err: any) => toastError(err, '创建失败'),
})
const submitMutation = useMutation({
@@ -206,7 +215,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
onError: (err: any) => toastError(err, '提交失败'),
})
const cancelMutation = useMutation({
@@ -218,7 +227,7 @@ export default function WorkProcess() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '撤销失败'),
onError: (err: any) => toastError(err, '撤销失败'),
})
const deleteMutation = useMutation({
@@ -229,7 +238,7 @@ export default function WorkProcess() {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
onError: (err: any) => toastError(err, '删除失败'),
})
const previewMutation = useMutation({
@@ -239,7 +248,7 @@ export default function WorkProcess() {
onSuccess: (data) => {
setPreviewContent(data.content)
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '预览失败'),
onError: (err: any) => toastError(err, '预览失败'),
})
const handleCreate = () => {
@@ -282,6 +291,21 @@ export default function WorkProcess() {
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
// 选择员工后自动填充相关字段
if (key === 'employeeId' && value) {
employeeApi.detail(value).then((emp: any) => {
setFormData(prev => ({
...prev,
employeeName: emp.name || prev.employeeName,
idCardNumber: emp.idCardNumber || prev.idCardNumber,
position: emp.position || prev.position,
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
department: emp.department || prev.department,
phone: emp.phone || prev.phone,
}))
}).catch(() => {})
}
}
const handleBatchSubmit = () => {
@@ -349,12 +373,21 @@ export default function WorkProcess() {
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-amber-600 flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
稿{PROCESS_TYPES[selectedType]?.label}
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}></button>
</span>
)}
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 13类流程卡片 */}
@@ -452,7 +485,7 @@ export default function WorkProcess() {
</Card>
{/* 创建/编辑弹窗 */}
<Modal open={showCreate} onClose={() => { setShowCreate(false); setFormData({}); setSelectedType('') }} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
{!selectedType ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
@@ -492,7 +525,16 @@ export default function WorkProcess() {
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
handleFieldChange(field.key, emp.id)
if (emp.name) handleFieldChange('employeeName', emp.name)
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
if (emp.position) handleFieldChange('position', emp.position)
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
if (emp.department) handleFieldChange('department', emp.department)
if (emp.phone) handleFieldChange('phone', emp.phone)
}} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
@@ -513,7 +555,7 @@ export default function WorkProcess() {
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
<Button variant="secondary" onClick={() => setSelectedType('')}>
</Button>
</div>
@@ -743,7 +785,7 @@ function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
@@ -792,7 +834,7 @@ function EmployeeSelect({ value, onChange }: { value: string; onChange: (v: stri
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e.id)
onChange(e)
setOpen(false)
setSearch('')
}}
+81 -7
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X, Plus } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -18,6 +18,8 @@ export function OvertimeCalculator() {
const [previewData, setPreviewData] = useState<any[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
const [showAddForm, setShowAddForm] = useState(false)
const [addForm, setAddForm] = useState({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
// 加班费规则配置
const { data: config, isLoading: configLoading } = useQuery<any>({
@@ -60,6 +62,17 @@ export function OvertimeCalculator() {
},
})
// 从考勤记录同步加班工时
const syncFromAttendanceMutation = useMutation({
mutationFn: (m: string) => payrollApi.syncOvertimeFromAttendance(m),
onSuccess: (data: any) => {
toast.success(`已从考勤同步 ${data.synced || 0} 位员工的加班工时`)
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setStep(3)
},
onError: () => toast.error('同步失败'),
})
// 更新单条加班记录
const updateOvertimeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
@@ -87,6 +100,18 @@ export function OvertimeCalculator() {
}
}
// 手动添加加班记录
const addOvertimeMutation = useMutation({
mutationFn: (data: any) => payrollApi.saveOvertime(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setShowAddForm(false)
setAddForm({ employeeId: '', weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
toast.success('加班记录已添加')
},
onError: () => toast.error('添加失败'),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -265,13 +290,18 @@ export function OvertimeCalculator() {
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
</div>
<div className="border-t pt-3">
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
</Button>
<div className="flex gap-2 items-center">
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择文件导入'}
</Button>
<Button variant="secondary" onClick={() => syncFromAttendanceMutation.mutate(month)} disabled={syncFromAttendanceMutation.isPending}>
{syncFromAttendanceMutation.isPending ? '同步中...' : '从考勤同步'}
</Button>
</div>
<div className="text-xs text-gray-500 mt-2">
CSV格式,(h),(h),(h),()
,(h),(h),(h),()
</div>
</div>
@@ -329,8 +359,52 @@ export function OvertimeCalculator() {
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
<Button variant="secondary" size="sm" onClick={() => refetch()}></Button>
<Button size="sm" onClick={() => setShowAddForm(!showAddForm)}><Plus className="w-3.5 h-3.5 mr-1" /></Button>
</div>
</div>
{showAddForm && (
<div className="border rounded-lg p-3 mb-3 bg-gray-50 space-y-3">
<div className="grid md:grid-cols-4 gap-3">
<div>
<Label></Label>
<select
className="w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
value={addForm.employeeId}
onChange={(e) => setAddForm({ ...addForm, employeeId: e.target.value })}
>
<option value=""></option>
{employees?.items?.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</select>
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekdayHours} onChange={(e) => setAddForm({ ...addForm, weekdayHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.weekendHours} onChange={(e) => setAddForm({ ...addForm, weekendHours: Number(e.target.value) })} />
</div>
<div>
<Label>(h)</Label>
<Input type="number" step="0.5" min="0" value={addForm.holidayHours} onChange={(e) => setAddForm({ ...addForm, holidayHours: Number(e.target.value) })} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => {
if (!addForm.employeeId) return toast.error('请选择员工')
const emp = employees?.items?.find((e: any) => e.id === addForm.employeeId)
let monthlyWage = 0
try { monthlyWage = Number((emp as any)?.monthlySalary) || 0 } catch {}
addOvertimeMutation.mutate({ ...addForm, month, monthlyWage: monthlyWage || 1 })
}} disabled={addOvertimeMutation.isPending || !addForm.employeeId}>
{addOvertimeMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAddForm(false)}></Button>
</div>
</div>
)}
{!overtimeRecords || overtimeRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
+5 -3
View File
@@ -14,7 +14,7 @@ import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager() {
export function PayslipManager({ filterEmployeeId }: { filterEmployeeId?: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
@@ -31,9 +31,11 @@ export function PayslipManager() {
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryKey: ['payslips', month, filterEmployeeId],
queryFn: async () => {
return await payrollApi.payslips({ month })
const all = await payrollApi.payslips({ month })
if (!filterEmployeeId) return all
return all.filter((p: any) => p.employeeId === filterEmployeeId)
},
})
+47 -4
View File
@@ -1,8 +1,8 @@
import { QRCodeSVG } from "qrcode.react"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi, socialInsuranceApi } from '../../lib/api-services'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -58,6 +58,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const [form, setForm] = useState({
department: profile.department || '',
position: profile.position || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
@@ -86,6 +87,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
onError: (err: any) => {
const details = err?.response?.data?.error?.details
if (details?.length > 0) {
toast.error(details.map((d: any) => `${d.path}: ${d.message}`).join(''))
} else {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
},
})
// 查询社保费用明细(按险种分别计算)
const { data: socialDetail } = useQuery<any>({
queryKey: ['social-calc', profile.id, profile.socialInsBase, profile.city],
queryFn: async () => {
if (!profile.socialInsBase || !profile.city) return null
try {
return await socialInsuranceApi.calculate(Number(profile.socialInsBase), profile.city)
} catch { return null }
},
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
const handleSave = () => {
@@ -113,6 +134,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
education: form.education || undefined,
position: form.position || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
@@ -128,6 +150,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '学历', value: profile.education || '未填写' },
{ label: '职务/岗位', value: profile.position || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
@@ -302,6 +325,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
@@ -333,6 +357,25 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
{socialDetail?.items?.length > 0 && (
<div className="md:col-span-4 mt-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="grid md:grid-cols-5 gap-2">
{socialDetail.items.map((item: any) => (
<div key={item.name} className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{item.name}</div>
<div className="text-gray-500 mt-0.5"> ¥{fmt(item.orgAmount)}{item.orgRate}%</div>
<div className="text-gray-500"> ¥{fmt(item.empAmount)}{item.empRate}%</div>
</div>
))}
</div>
{socialDetail.capped && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.floored && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(socialDetail.actualBase)}</div>}
{socialDetail.medicalBase && socialDetail.medicalBase !== socialDetail.actualBase && (
<div className="text-xs text-blue-600 mt-1">¥{fmt(socialDetail.medicalBase)}</div>
)}
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
@@ -342,11 +385,11 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label>/</Label>
+85 -30
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from "react"
import mammoth from "mammoth"
import api from '../../lib/api'
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
@@ -18,18 +19,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('附件')
const [wordHtml, setWordHtml] = useState<string | null>(null)
const uploadAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('附件已上传')
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已更新')
},
onError: () => toast.error('上传失败'),
})
const deleteAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-detail'] })
toast.success('附件已删除')
},
onError: () => toast.error('删除失败'),
})
const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => {
if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return
const newAtts = atts.filter((_, i) => i !== idx)
deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' })
}
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
const files = e.target.files
if (!files || files.length === 0) return
@@ -332,31 +351,42 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<button onClick={() => { setPreviewName(att.name); setPreviewUrl(att.url) }} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<button
type="button"
className="text-gray-400 hover:text-primary ml-2 shrink-0"
title="下载附件"
onClick={() => {
const dataToBlobUrl = (dataUrl: string) => {
try {
const arr = dataUrl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
const bstr = atob(arr[1])
const u8 = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
return URL.createObjectURL(new Blob([u8], { type: mime }))
} catch { return dataUrl }
}
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</button>
<div className="flex items-center gap-1 ml-2 shrink-0">
<button
type="button"
className="text-gray-400 hover:text-primary"
title="下载附件"
onClick={() => {
const dataToBlobUrl = (dataUrl: string) => {
try {
const arr = dataUrl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
const bstr = atob(arr[1])
const u8 = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
return URL.createObjectURL(new Blob([u8], { type: mime }))
} catch { return dataUrl }
}
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</button>
<button
type="button"
className="text-gray-400 hover:text-danger"
title="删除附件"
disabled={deleteAttachmentMutation.isPending}
onClick={() => handleDeleteAttachment(c.id, atts, idx)}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))}
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
@@ -405,12 +435,28 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx')
// 如果是 Word 文件且尚未转换,异步转换
if (isWord && !wordHtml) {
fetch(blobUrl)
.then(r => r.arrayBuffer())
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
.then(result => setWordHtml(result.value))
.catch(() => setWordHtml('<p style="text-align:center;color:#999;">Word 文件转换失败,请下载查看</p>'))
}
const closePreview = () => {
if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl)
setPreviewUrl(null)
setWordHtml(null)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={closePreview}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium"> - {previewName}</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => {
const a = document.createElement('a')
@@ -422,7 +468,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
}} className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</button>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<button onClick={closePreview} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
@@ -432,6 +478,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : isWord ? (
wordHtml ? (
<div className="prose prose-sm max-w-none w-full" dangerouslySetInnerHTML={{ __html: wordHtml }} />
) : (
<div className="text-center space-y-3">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-gray-500"> Word ...</p>
</div>
)
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
@@ -63,7 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
<>
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
+69 -2
View File
@@ -1,14 +1,18 @@
import { useState } from "react"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import { rosterApi, evidenceApi } from '../../lib/api-services'
import { useAuthStore } from "../../store/authStore"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { AlertTriangle, Scale } from "lucide-react"
import { AlertTriangle, Scale, ShieldCheck } from "lucide-react"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const [verifyResult, setVerifyResult] = useState<any>(null)
const [verifying, setVerifying] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
@@ -16,6 +20,24 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
},
})
const handleVerify = async () => {
setVerifying(true)
try {
const records = await evidenceApi.byEmployee(employeeId)
const results: any[] = []
for (const r of records) {
const result = await evidenceApi.verify(r.id)
results.push({ id: r.id, category: r.category, refId: r.refId, ...result })
}
setVerifyResult({ total: records.length, valid: results.filter(r => r.valid).length, invalid: results.filter(r => !r.valid).length, details: results })
toast.success(`验证完成:${results.filter(r => r.valid).length}/${results.length} 条有效`)
} catch {
toast.error('验证失败')
} finally {
setVerifying(false)
}
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
if (!data.evidence || data.evidence.length === 0) return (
@@ -104,6 +126,10 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</div>
)}
<Button onClick={handleExport}></Button>
<Button variant="secondary" onClick={handleVerify} disabled={verifying}>
<ShieldCheck className="w-4 h-4 mr-1" />
{verifying ? '验证中...' : '验证完整性'}
</Button>
</div>
</div>
</Card>
@@ -129,6 +155,47 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
</Card>
)}
{verifyResult && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-safe" />
</h3>
<div className="flex items-center gap-4 mb-3">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold">{verifyResult.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-safe">{verifyResult.valid}</div>
</div>
{verifyResult.invalid > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-danger">{verifyResult.invalid}</div>
</div>
)}
</div>
{verifyResult.invalid > 0 && (
<div className="space-y-1">
{verifyResult.details.filter((r: any) => !r.valid).map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.category}</span>
{r.refId && <span className="text-xs opacity-70 ml-2">ID: {r.refId}</span>}
<div className="mt-0.5 opacity-90"> {r.expectedHash?.slice(0, 16)}... {r.actualHash?.slice(0, 16)}...</div>
</div>
))}
</div>
)}
{verifyResult.invalid === 0 && (
<div className="text-xs text-safe flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
</div>
)}
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
+25 -14
View File
@@ -1,4 +1,5 @@
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { socialInsuranceApi } from '../../lib/api-services'
@@ -7,26 +8,36 @@ import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { fmt } from "./shared"
import { ExternalLink } from "lucide-react"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords, employeeId }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[]; employeeId?: string }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const navigate = useNavigate()
return (
<div className="space-y-3">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
</div>
{employeeId && (
<Button variant="secondary" size="sm" onClick={() => navigate(`/money?employeeId=${employeeId}&tab=payslip`)}>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
{subTab === 'payslip' && (
+80 -9
View File
@@ -1,5 +1,5 @@
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
@@ -11,11 +11,17 @@ import { AlertTriangle, Check } from "lucide-react"
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '', templateId: '' })
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>({})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const createMutation = useMutation({
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false); setDimensionScores({}) },
})
const deleteMutation = useMutation({
@@ -38,6 +44,32 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
setForm({ ...form, score, grade, result })
}
const selectedTemplate = (templates || []).find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.dimensionScores = dimensionScores
}
createMutation.mutate(data)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
@@ -56,26 +88,58 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
</Select>
</div>
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(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>
<div className="md:col-span-2"><Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{(templates || []).map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="md:col-span-2 border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div><Label></Label><Input type="number" value={form.score} readOnly className="bg-gray-50" /></div>
<div><Label></Label><Input value={form.grade} readOnly className="bg-gray-50" /></div>
</div>
</div>
) : (
<>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(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><Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="perfAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={handleSubmit} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
@@ -93,6 +157,13 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"> {r.score} · {r.grade}</span>
</div>
{r.dimensionScores && Object.keys(r.dimensionScores).length > 0 && (
<div className="flex flex-wrap gap-1">
{Object.entries(r.dimensionScores).map(([name, score]: [string, any]) => (
<span key={name} className="text-xs px-2 py-0.5 rounded bg-gray-50 text-gray-600">{name}: {score}</span>
))}
</div>
)}
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
{r.improvementPlan && (
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
+301 -20
View File
@@ -1,7 +1,7 @@
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 { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
@@ -19,6 +19,7 @@ export default function PerformanceRecords() {
const [keyword, setKeyword] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['performance-list', page, pageSize, keyword],
@@ -30,6 +31,11 @@ export default function PerformanceRecords() {
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
@@ -71,9 +77,14 @@ export default function PerformanceRecords() {
<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 className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTemplateModal(true)}>
<LayoutTemplate className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
@@ -163,17 +174,26 @@ export default function PerformanceRecords() {
{(showCreate || editRecord) && (
<PerformanceForm
employees={employees || []}
templates={templates || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
{showTemplateModal && (
<TemplateModal
templates={templates || []}
onClose={() => setShowTemplateModal(false)}
/>
)}
</div>
)
}
function PerformanceForm({ employees, record, onSubmit, onClose }: {
function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
employees: any[]
templates: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
@@ -188,7 +208,12 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
summary: record?.summary || '',
improvementPlan: record?.improvementPlan || '',
reviewer: record?.reviewer || '',
templateId: record?.templateId || '',
})
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>(record?.dimensionScores || {})
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
@@ -202,6 +227,31 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
setForm({ ...form, score, grade, result })
}
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
// 按权重计算总分
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.templateId = form.templateId
data.dimensionScores = dimensionScores
}
onSubmit(data)
}
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()}>
@@ -235,21 +285,62 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
<Label></Label>
<Input type={form.periodType === 'YEARLY' ? 'number' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(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>
<Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{templates.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
{d.description && <span className="text-xs text-gray-400 ml-1">({d.description})</span>}
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="text-xs text-gray-500 pt-1 border-t"></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(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>
)}
{dimensions.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.score} readOnly className="bg-gray-50" />
</div>
<div>
<Label></Label>
<Input value={form.grade} readOnly className="bg-gray-50" />
</div>
</div>
)}
<div>
<Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
@@ -285,10 +376,200 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
)}
<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>
<Button size="sm" onClick={handleSubmit} disabled={!form.employeeId || !form.period}></Button>
</div>
</div>
</div>
</div>
)
}
function TemplateModal({ templates, onClose }: {
templates: any[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [showForm, setShowForm] = useState(false)
const createMut = useMutation({
mutationFn: (data: any) => rosterApi.createPerformanceTemplate(data),
onSuccess: () => {
toast.success('模板已创建')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
},
onError: () => toast.error('创建失败'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => rosterApi.updatePerformanceTemplate(id, data),
onSuccess: () => {
toast.success('模板已更新')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
setEditing(null)
},
onError: () => toast.error('更新失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => rosterApi.deletePerformanceTemplate(id),
onSuccess: () => {
toast.success('模板已删除')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
},
})
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-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" onClick={() => { setEditing(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
</div>
{showForm ? (
<TemplateForm
template={editing}
onSubmit={(data) => {
if (editing) {
updateMut.mutate({ id: editing.id, data })
} else {
createMut.mutate(data)
}
}}
onClose={() => { setShowForm(false); setEditing(null) }}
/>
) : (
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
</div>
) : templates.map((t: any) => (
<div key={t.id} className="border border-gray-200 rounded-md p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
{t.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditing(t); setShowForm(true) }} 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(t.id) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</div>
{t.description && <p className="text-xs text-gray-500 mt-1">{t.description}</p>}
<div className="flex flex-wrap gap-1 mt-2">
{(t.dimensions as any[]).map((d: any) => (
<span key={d.name} className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{d.name}{d.weight}%
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function TemplateForm({ template, onSubmit, onClose }: {
template: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [name, setName] = useState(template?.name || '')
const [description, setDescription] = useState(template?.description || '')
const [isDefault, setIsDefault] = useState(template?.isDefault || false)
const [dimensions, setDimensions] = useState<any[]>(
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
)
const addDimension = () => {
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
}
const removeDimension = (idx: number) => {
setDimensions(dimensions.filter((_, i) => i !== idx))
}
const updateDimension = (idx: number, field: string, value: any) => {
setDimensions(dimensions.map((d, i) => i === idx ? { ...d, [field]: value } : d))
}
const totalWeight = dimensions.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
const canSubmit = name && dimensions.every(d => d.name) && totalWeight === 100
return (
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:月度绩效考核表" />
</div>
<div>
<Label></Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="模板用途说明(选填)" />
</div>
<div>
<Label> *</Label>
<div className="space-y-2">
{dimensions.map((d, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center border border-gray-200 rounded p-2">
<div className="col-span-3">
<Input value={d.name} onChange={(e) => updateDimension(idx, 'name', e.target.value)} placeholder="维度名称" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={0} max={100} value={d.weight} onChange={(e) => updateDimension(idx, 'weight', Number(e.target.value))} placeholder="权重%" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={1} value={d.maxScore} onChange={(e) => updateDimension(idx, 'maxScore', Number(e.target.value))} placeholder="满分" className="text-sm" />
</div>
<div className="col-span-4">
<Input value={d.description || ''} onChange={(e) => updateDimension(idx, 'description', e.target.value)} placeholder="说明(选填)" className="text-sm" />
</div>
<div className="col-span-1">
{dimensions.length > 1 && (
<button onClick={() => removeDimension(idx)} className="p-1 hover:bg-gray-100 rounded">
<X className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-2">
<button onClick={addDimension} className="text-xs text-primary hover:underline">+ </button>
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>{totalWeight}%</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
</Button>
</div>
{!canSubmit && totalWeight !== 100 && (
<div className="text-xs text-amber-600">100%</div>
)}
</div>
)
}
+121 -11
View File
@@ -1,13 +1,14 @@
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 { Search, Plus, Edit2, Trash2, X, Bell, Users, Check } 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'
import Modal from '../../components/ui/Modal'
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' }
@@ -22,12 +23,13 @@ export default function TrainingRecords() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [filterAckStatus, setFilterAckStatus] = 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 }),
queryKey: ['training-list', page, pageSize, keyword, filterAckStatus],
queryFn: () => rosterApi.trainingList({ page, pageSize, keyword, ackStatus: filterAckStatus }),
})
const { data: employees } = useQuery({
@@ -49,6 +51,16 @@ export default function TrainingRecords() {
onError: () => toast.error('添加失败'),
})
const batchCreateMut = useMutation({
mutationFn: (data: any) => api.post('/roster/training/batch', data),
onSuccess: (data: any) => {
toast.success(`已为 ${data?.count || 0} 名员工添加培训记录`)
queryClient.invalidateQueries({ queryKey: ['training-list'] })
setShowCreate(false)
},
onError: () => toast.error('批量添加失败'),
})
const updateMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
@@ -74,6 +86,14 @@ export default function TrainingRecords() {
},
})
const remindMut = useMutation({
mutationFn: (recordId: string) => rosterApi.trainingRemind(recordId),
onSuccess: (data: any) => {
toast.success(data?.message || '催办已发送')
},
onError: () => toast.error('催办失败'),
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
@@ -100,6 +120,16 @@ export default function TrainingRecords() {
className="pl-9"
/>
</div>
<Select
value={filterAckStatus}
onChange={(e) => { setFilterAckStatus(e.target.value); setPage(1) }}
className="w-32"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="SIGNED"></option>
<option value="REFUSED"></option>
</Select>
</div>
<div className="overflow-x-auto">
@@ -138,6 +168,16 @@ export default function TrainingRecords() {
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{r.ackStatus === 'PENDING' && (
<button
onClick={() => remindMut.mutate(r.id)}
disabled={remindMut.isPending}
className="p-1 hover:bg-gray-100 rounded"
title="催办签收"
>
<Bell className="w-3.5 h-3.5 text-amber-500" />
</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>
@@ -173,6 +213,8 @@ export default function TrainingRecords() {
onSubmit={(data) => {
if (editRecord) {
updateMut.mutate({ ...data, employeeId: editRecord.employeeId, recordId: editRecord.id })
} else if (data.employeeIds) {
batchCreateMut.mutate(data)
} else {
createMut.mutate(data)
}
@@ -190,6 +232,9 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
onSubmit: (data: any) => void
onClose: () => void
}) {
const [batchMode, setBatchMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
trainingDate: record?.trainingDate ? new Date(record.trainingDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10),
@@ -200,6 +245,24 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
remark: record?.remark || '',
})
const filteredEmployees = batchSearch
? employees.filter((e: any) => e.name.includes(batchSearch) || (e.department || '').includes(batchSearch))
: employees
const toggleEmployee = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
const handleSubmit = () => {
if (batchMode) {
onSubmit({ ...form, employeeIds: selectedIds })
} else {
onSubmit(form)
}
}
const canSubmit = batchMode ? selectedIds.length > 0 && form.topic : form.employeeId && form.topic
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()}>
@@ -210,13 +273,60 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
<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 className="flex items-center justify-between mb-1">
<Label>{batchMode ? '批量选择员工' : '员工'}</Label>
<button
className="text-xs text-primary hover:underline flex items-center gap-1"
onClick={() => { setBatchMode(!batchMode); setSelectedIds([]) }}
>
<Users className="w-3.5 h-3.5" />
{batchMode ? '切换为单选' : '切换为批量'}
</button>
</div>
{batchMode ? (
<div className="border border-gray-200 rounded-md">
<div className="p-2 border-b border-gray-100">
<input
type="text"
placeholder="搜索姓名/部门"
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="max-h-[180px] overflow-y-auto">
{filteredEmployees.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={selectedIds.includes(emp.id)}
onChange={() => toggleEmployee(emp.id)}
className="rounded"
/>
<span>{emp.name}</span>
<span className="text-gray-400 text-xs">{emp.department || ''}</span>
</label>
))}
</div>
{selectedIds.length > 0 && (
<div className="px-3 py-1.5 border-t border-gray-100 text-xs text-primary">
{selectedIds.length}
</div>
)}
</div>
) : (
<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>
@@ -258,7 +368,7 @@ function TrainingForm({ employees, record, onSubmit, onClose }: {
</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>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}></Button>
</div>
</div>
</div>
+77 -19
View File
@@ -1,6 +1,6 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { rosterApi, socialInsuranceApi } from '../../lib/api-services'
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
@@ -395,7 +395,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -403,7 +403,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={employee?.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -510,17 +510,35 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
const [form, setForm] = useState(() => {
try {
const saved = localStorage.getItem('add-employee-draft')
if (saved) return JSON.parse(saved)
} catch {}
return {
name: '', department: '', position: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京', education: '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
}
})
// 持久化草稿到 localStorage,防止录入数据丢失
useEffect(() => {
try {
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
if (isDirty) {
localStorage.setItem('add-employee-draft', JSON.stringify(form))
} else {
localStorage.removeItem('add-employee-draft')
}
} catch {}
}, [form])
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
@@ -536,7 +554,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)+ 查重
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
@@ -544,6 +563,12 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
}
setForm({ ...form, idCardNumber: idCard, gender })
setIdCardDuplicate(null)
if (idCard.length === 18) {
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
setIdCardDuplicate(data)
}).catch(() => {})
}
}
// 计算合同月数
@@ -610,6 +635,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
position: form.position || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
@@ -642,18 +668,29 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
useUnsavedChanges(isDirty)
return (
<Modal open onClose={onClose} title="添加员工" size="xl">
<Modal open onClose={onClose} title="添加员工" size="xl" closeOnOverlayClick={false}>
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
{error.response?.data?.error?.message || '操作失败'}
{error.response?.data?.error?.details?.length > 0
? error.response.data.error.details.map((d: any, i: number) => (
<div key={i}> {d.path}: {d.message}</div>
))
: (error.response?.data?.error?.message || '操作失败')}
</div>
)}
{/* 基本信息 */}
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label>/</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
{idCardDuplicate?.exists && (
<div className="col-span-4 px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{idCardDuplicate.employee?.name}{idCardDuplicate.employee?.department}</span>
</div>
)}
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
@@ -665,7 +702,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.city} onChange={async (e) => {
const city = e.target.value
setForm({ ...form, city })
const salary = Number(form.socialInsBase === '' ? form.monthlySalary : form.socialInsBase) || 0
const hfBase = Number(form.housingFundBase === '' ? form.monthlySalary : form.housingFundBase) || 0
if (salary > 0) {
try {
const res = await socialInsuranceApi.calculate(salary, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, socialInsBase: String(res.actualBase) }))
}
} catch {}
}
if (hfBase > 0) {
try {
const res = await socialInsuranceApi.housingCalculate(hfBase, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, housingFundBase: String(res.actualBase) }))
}
} catch {}
}
}}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
</div>
{/* 社保公积金 */}
@@ -678,7 +736,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>
@@ -686,7 +744,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
</div>
<div>
<Label></Label>