feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税
- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化 - 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口 - 所有导入模板表头标注必填项(*后缀)并含示例行 - 导入逻辑统一改用getField兼容*后缀列名 - 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题 - 更新需求梳理文档
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane } from 'lucide-react'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
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'
|
||||
@@ -88,8 +89,14 @@ export default function Attendance() {
|
||||
|
||||
// ========== 考勤确认 Tab ==========
|
||||
function ConfirmTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [filterDepartment, setFilterDepartment] = useState('')
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importResult, setImportResult] = useState<any>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['attendance', month, filterDepartment],
|
||||
@@ -120,6 +127,9 @@ function ConfirmTab() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入考勤
|
||||
</Button>
|
||||
<select
|
||||
value={filterDepartment}
|
||||
onChange={e => setFilterDepartment(e.target.value)}
|
||||
@@ -195,6 +205,99 @@ function ConfirmTab() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入考勤弹窗 */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
|
||||
<Card className="max-w-lg w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">导入考勤数据 — {month}</h2>
|
||||
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={async () => {
|
||||
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`, {
|
||||
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.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('下载模板失败') }
|
||||
}}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />下载模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
|
||||
模板中「考勤记录」Sheet 包含:姓名、身份证号、日期、考勤状态、上下班时间。身份证号优先匹配,未填时用姓名匹配。
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="attendance-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
|
||||
<label htmlFor="attendance-import-file" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{importFile ? importFile.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
{importResult.attendance > 0 && <div>考勤记录:{importResult.attendance} 条</div>}
|
||||
{importResult.overtime > 0 && <div>加班记录:{importResult.overtime} 条</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>}
|
||||
{importResult.errors?.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-green-200">
|
||||
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
|
||||
{importResult.errors.length > 5 && <div className="text-amber-600">...还有 {importResult.errors.length - 5} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}>取消</Button>
|
||||
<Button size="sm" onClick={async () => {
|
||||
if (!importFile) return toast.error('请选择文件')
|
||||
setImporting(true)
|
||||
setImportResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile)
|
||||
const res = await fetch('/api/v1/import/excel', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) { toast.error(data.error?.message || '导入失败') }
|
||||
else {
|
||||
setImportResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
|
||||
toast.success('考勤数据导入完成')
|
||||
}
|
||||
} catch (e: any) { toast.error(e?.message || '导入失败') }
|
||||
finally { setImporting(false) }
|
||||
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,89 @@ export default function Money() {
|
||||
|
||||
// ========== 发薪批次管理 ==========
|
||||
|
||||
function CustomEmployeeSelector({ selectedIds, onChange }: { selectedIds: string[]; onChange: (ids: string[]) => void }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterDept, setFilterDept] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any>({
|
||||
queryKey: ['roster-for-batch', search, filterDept],
|
||||
queryFn: async () => {
|
||||
const params: any = { pageSize: 999 }
|
||||
if (search) params.search = search
|
||||
if (filterDept) params.department = filterDept
|
||||
params.status = 'ACTIVE'
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: deptList } = useQuery<string[]>({
|
||||
queryKey: ['roster-departments'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/departments') as any
|
||||
return res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter(x => x !== id))
|
||||
} else {
|
||||
onChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (employees && employees.every((e: any) => selectedIds.includes(e.id))) {
|
||||
onChange(selectedIds.filter(id => !employees.some((e: any) => e.id === id)))
|
||||
} else {
|
||||
const newIds = new Set([...selectedIds, ...(employees?.map((e: any) => e.id) || [])])
|
||||
onChange(Array.from(newIds))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>选择发薪人员</Label>
|
||||
<span className="text-xs text-gray-500">已选 {selectedIds.length} 人</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="搜索姓名" value={search} onChange={(e) => setSearch(e.target.value)} className="!w-40" />
|
||||
<select value={filterDept} onChange={(e) => setFilterDept(e.target.value)} className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm">
|
||||
<option value="">全部部门</option>
|
||||
{deptList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
{employees && employees.length > 0 && (
|
||||
<button onClick={toggleAll} className="text-xs text-primary hover:underline whitespace-nowrap">
|
||||
{employees.every((e: any) => selectedIds.includes(e.id)) ? '取消全选' : '全选当前'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border rounded">
|
||||
{employees && employees.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{employees.map((e: any) => (
|
||||
<tr key={e.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => toggle(e.id)}>
|
||||
<td className="px-2 py-1.5 w-8">
|
||||
<input type="checkbox" checked={selectedIds.includes(e.id)} onChange={() => toggle(e.id)} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 font-medium">{e.name}</td>
|
||||
<td className="px-2 py-1.5 text-gray-500">{e.department}</td>
|
||||
<td className="px-2 py-1.5 text-gray-400 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="py-4 text-center text-gray-400 text-xs">暂无员工</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BatchManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
@@ -73,8 +156,9 @@ function BatchManager() {
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
|
||||
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch'>('copy_last')
|
||||
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
|
||||
const [sourceBatchId, setSourceBatchId] = useState<string>('')
|
||||
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
@@ -206,11 +290,6 @@ function BatchManager() {
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button onClick={() => {
|
||||
const hasDraft = batches?.some((b: any) => b.status === 'DRAFT' && b.month === month)
|
||||
if (hasDraft) {
|
||||
toast.error('当月存在未归档的批次,请先归档后再创建新批次')
|
||||
return
|
||||
}
|
||||
setCreateType('REGULAR'); setShowCreateModal(true)
|
||||
}} className="shrink-0">
|
||||
<Plus className="w-4 h-4 mr-1" />创建发薪批次
|
||||
@@ -233,11 +312,12 @@ function BatchManager() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据初始化模式</Label>
|
||||
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId('') }}>
|
||||
<Select value={createMode} onChange={(e) => { setCreateMode(e.target.value as any); setSourceBatchId(''); setSelectedEmployeeIds([]) }}>
|
||||
<option value="copy_last">复制上月数据</option>
|
||||
<option value="blank_employees">本月空白(拉入员工,金额为0)</option>
|
||||
<option value="blank_all">全空白(不拉入员工)</option>
|
||||
<option value="copy_batch">复制指定批次</option>
|
||||
<option value="custom">自定义选择员工</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,16 +335,20 @@ function BatchManager() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{createMode === 'custom' && (
|
||||
<CustomEmployeeSelector selectedIds={selectedEmployeeIds} onChange={setSelectedEmployeeIds} />
|
||||
)}
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
{createMode === 'copy_last' && <p>拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动计算社保个税</p>}
|
||||
{createMode === 'blank_employees' && <p>拉入在职员工,所有金额为0,需手动填写</p>}
|
||||
{createMode === 'blank_all' && <p>创建空批次,不拉入员工,后续手动添加人员和填写数据</p>}
|
||||
{createMode === 'copy_batch' && <p>从指定的已归档批次复制员工和薪资数据</p>}
|
||||
{createMode === 'custom' && <p>按部门筛选、搜索、勾选指定员工创建批次</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined })}
|
||||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId)}
|
||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
|
||||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,8 @@ export default function Roster() {
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
if (debouncedSearch) params.search = debouncedSearch
|
||||
if (filterStatus) params.status = filterStatus
|
||||
if (filterStatus && filterStatus !== 'PROBATION') params.status = filterStatus
|
||||
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||
if (filterDepartment) params.department = filterDepartment
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
@@ -59,7 +60,10 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const employees = rosterData?.data || []
|
||||
const employees = (rosterData?.data || []).filter((e: any) => {
|
||||
if (filterStatus === 'PROBATION') return e.probationInfo?.isProbation
|
||||
return true
|
||||
})
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
const addMutation = useMutation({
|
||||
@@ -255,6 +259,7 @@ export default function Roster() {
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">在职</option>
|
||||
<option value="PROBATION">试用期</option>
|
||||
<option value="PRE_HIRE">预入职</option>
|
||||
<option value="RESIGNED">离职</option>
|
||||
</select>
|
||||
@@ -388,6 +393,15 @@ export default function Roster() {
|
||||
}`}>
|
||||
{e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'}
|
||||
</span>
|
||||
{e.probationInfo?.isProbation && (
|
||||
<span className={`ml-1 px-2 py-0.5 rounded text-xs ${
|
||||
e.probationInfo.isExpiring
|
||||
? 'bg-orange-50 text-orange-700 border border-orange-200'
|
||||
: 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||
}`}>
|
||||
试用期{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `剩${e.probationInfo.daysToConfirm}天`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
|
||||
@@ -1115,16 +1115,50 @@ function InitImport() {
|
||||
{result && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
<div>员工:{result.employees} 人</div>
|
||||
<div>合同:{result.contracts} 份</div>
|
||||
{result.overtime > 0 && <div>加班记录:{result.overtime} 条</div>}
|
||||
{result.disciplinary > 0 && <div>违纪记录:{result.disciplinary} 条</div>}
|
||||
{result.attendance > 0 && <div>考勤记录:{result.attendance} 条</div>}
|
||||
<div className="flex gap-4">
|
||||
<span>成功导入:员工 {result.employees} 人、合同 {result.contracts} 份</span>
|
||||
{result.overtime > 0 && <span>加班 {result.overtime} 条</span>}
|
||||
{result.disciplinary > 0 && <span>违纪 {result.disciplinary} 条</span>}
|
||||
{result.attendance > 0 && <span>考勤 {result.attendance} 条</span>}
|
||||
</div>
|
||||
{result.skipped > 0 && (
|
||||
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
||||
)}
|
||||
{result.duplicates > 0 && (
|
||||
<div className="text-amber-600">重复 {result.duplicates} 条(身份证号已存在)</div>
|
||||
)}
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<div className="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||||
<div className="font-medium text-amber-600 flex items-center justify-between">
|
||||
<span>错误详情({result.errors.length}条):</span>
|
||||
<button className="text-xs text-primary hover:underline" onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const errorList = result.errors.map((e: string, i: number) => {
|
||||
const detail = result.details?.[i] || {}
|
||||
return { sheet: detail.sheet || '员工信息', row: detail.row || i + 2, name: detail.name || '', errors: [e] }
|
||||
})
|
||||
const res = await fetch('/api/v1/import/excel/error-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ errors: errorList }),
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '导入错误日志.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('导出错误日志失败')
|
||||
}
|
||||
}}>
|
||||
导出错误日志
|
||||
</button>
|
||||
</div>
|
||||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条</div>}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条,请导出错误日志查看全部</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
@@ -1219,6 +1220,11 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<any>(null)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importResult, setImportResult] = useState<any>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 查询当月所有员工的专项附加扣除
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
@@ -1326,6 +1332,13 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
>
|
||||
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />批量导入
|
||||
</Button>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1439,6 +1452,91 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 批量导入弹窗 */}
|
||||
{showImport && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
|
||||
<Card className="max-w-lg w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">批量导入专项附加扣除 — {month}</h2>
|
||||
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={async () => {
|
||||
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/special-deduction/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.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('下载模板失败') }
|
||||
}}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />下载模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
|
||||
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{importFile ? importFile.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
<div>成功 {importResult.updated} 条,跳过 {importResult.skipped} 条,共 {importResult.total} 条</div>
|
||||
{importResult.errors?.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-green-200">
|
||||
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
|
||||
{importResult.errors.length > 5 && <div className="text-amber-600">...还有 {importResult.errors.length - 5} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}>取消</Button>
|
||||
<Button size="sm" onClick={async () => {
|
||||
if (!importFile) return toast.error('请选择文件')
|
||||
setImporting(true)
|
||||
setImportResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile)
|
||||
formData.append('month', month)
|
||||
const res = await fetch('/api/v1/import/special-deduction', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) { toast.error(data.error?.message || '导入失败') }
|
||||
else {
|
||||
setImportResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
||||
toast.success(`导入完成:成功 ${data.data.updated} 条`)
|
||||
}
|
||||
} catch (e: any) { toast.error(e?.message || '导入失败') }
|
||||
finally { setImporting(false) }
|
||||
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight } from 'lucide-react'
|
||||
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
@@ -100,11 +101,40 @@ export default function Templates() {
|
||||
}
|
||||
}
|
||||
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(rendered)
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseURL}/templates/${selected.id}/download`, {
|
||||
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 = `${selected.name}.doc`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('已下载 Word 文档')
|
||||
} catch {
|
||||
toast.error('下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyAsNew = () => {
|
||||
const text = rendered || detail?.content || ''
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('模板内容已复制,可粘贴到 Word 中编辑使用')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -113,6 +143,25 @@ export default function Templates() {
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">合同、制度、通知等常用文本模板,支持变量替换</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowHelp(!showHelp)}>
|
||||
<HelpCircle className="w-3.5 h-3.5 mr-1" />使用说明
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showHelp && (
|
||||
<Card className="bg-blue-50/50">
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex items-center gap-1.5 font-medium text-gray-700"><BookOpen className="w-3.5 h-3.5" />使用说明</div>
|
||||
<div>1. 选择需要的模板分类(合同/制度/通知/协议),点击模板卡片打开详情</div>
|
||||
<div>2. 在弹窗中填写变量字段(如公司名称、员工姓名等),点击「渲染模板」生成完整文本</div>
|
||||
<div>3. 渲染后可「复制」到剪贴板,或「下载 Word」保存为 .doc 文件</div>
|
||||
<div>4. 「复制为新模板」将渲染结果复制到剪贴板,可粘贴到 Word 中进一步编辑</div>
|
||||
<div>5. 变量字段为选填,未填写的变量将保留 <code className="px-1 bg-gray-100 rounded">{'{{变量名}}'}</code> 占位符</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
|
||||
<button
|
||||
@@ -187,15 +236,30 @@ export default function Templates() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">渲染结果</span>
|
||||
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCopyAsNew} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制为新模板
|
||||
</button>
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
|
||||
</div>
|
||||
) : detail?.content ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">模板原文</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">模板原文</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleDownloadWord} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Download className="w-3 h-3" />下载 Word
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import api from "../../lib/api"
|
||||
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"
|
||||
@@ -48,15 +49,23 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const text = generateEvidenceText(data)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch(`/api/v1/roster/${employeeId}/evidence-chain/export`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('导出失败')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const riskStyle: Record<string, string> = {
|
||||
|
||||
@@ -85,6 +85,48 @@ export default function HealthCheck() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 评分标准说明 */}
|
||||
<Card>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Stethoscope className="w-4 h-4 text-primary" />
|
||||
评分标准说明
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-safe" />
|
||||
<div>
|
||||
<div className="font-medium text-safe">85 分以上 · 健康</div>
|
||||
<div className="text-gray-500">用工管理规范,风险低</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-warning" />
|
||||
<div>
|
||||
<div className="font-medium text-warning">60-84 分 · 中等风险</div>
|
||||
<div className="text-gray-500">存在部分合规隐患</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-danger" />
|
||||
<div>
|
||||
<div className="font-medium text-danger">60 分以下 · 高风险</div>
|
||||
<div className="text-gray-500">需立即整改</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-2 text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-600">6 维度评分构成:</div>
|
||||
<div>- <b>合同管理</b>:合同签订率、到期预警、试用期合规</div>
|
||||
<div>- <b>薪酬社保</b>:社保覆盖率、公积金缴纳、薪资合规</div>
|
||||
<div>- <b>考勤加班</b>:加班时长合规、考勤记录完整性</div>
|
||||
<div>- <b>规章制度</b>:制度公示、民主程序、员工知情</div>
|
||||
<div>- <b>解聘合规</b>:解除程序合法、补偿计算准确</div>
|
||||
<div>- <b>证据链</b>:证据完整性、仲裁可追溯性</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 总评分 */}
|
||||
<Card className={level.bg}>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
Reference in New Issue
Block a user