feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税

- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化
- 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口
- 所有导入模板表头标注必填项(*后缀)并含示例行
- 导入逻辑统一改用getField兼容*后缀列名
- 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题
- 更新需求梳理文档
This commit is contained in:
freedakgmail
2026-07-29 19:08:45 +08:00
parent 7c24ebe3d9
commit 0372cbe243
14 changed files with 1275 additions and 171 deletions
+105 -2
View File
@@ -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>
)
}