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>
)
}
+93 -9
View File
@@ -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>
+16 -2
View File
@@ -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">
+41 -7
View File
@@ -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>
+100 -2
View File
@@ -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>
)
}
+69 -5
View File
@@ -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}
+18 -9
View File
@@ -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> = {
+42
View File
@@ -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">