feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>()),
|
||||
}
|
||||
|
||||
// ========== 审计日志 ==========
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />下载交接清单
|
||||
|
||||
@@ -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('')
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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' : ''}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user