feat: TurboHR 14项优化与功能增强

- #1 Dashboard风险提醒增加立刻办理按钮
- #2 Calendar月份选择器改为input month
- #3 Termination增加7种解聘原因法律依据和操作步骤
- #5 合同审查支持PDF TXT格式
- #6 AI合同审查prompt优化为具体修改建议
- #7 知识库添加更新机制说明
- #9 SpecialStatus员工选择改用all-lite接口
- #10 Termination增加详细法律条款引用
- #11 Money发薪批次增加社保公积金合计列
- #12 EmployeeAttachment扩展文件类型
- #13 花名册增加女职工干部工人选项加退休提醒
- #14 新增公司备用文件上传模块
This commit is contained in:
selfrelease
2026-08-01 13:47:49 +08:00
parent d9e2c610cf
commit f1a02f0439
20 changed files with 542 additions and 32 deletions
+172
View File
@@ -0,0 +1,172 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Building2, Upload, Trash2, FileText, AlertCircle, Calendar } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
const FILE_TYPES = [
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
{ value: 'WORK_HOURS', label: '工时备案' },
{ value: 'HR_POLICY', label: '制度文件' },
{ value: 'LABOR_CONTRACT_TEMPLATE', label: '合同模板' },
{ value: 'OTHER', label: '其他' },
]
const fileTypeLabels: Record<string, string> = Object.fromEntries(FILE_TYPES.map(t => [t.value, t.label]))
export default function CompanyFiles() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState('BUSINESS_LICENSE')
const [remark, setRemark] = useState('')
const [expiryDate, setExpiryDate] = useState('')
const [filterType, setFilterType] = useState('')
const { data: files, isLoading } = useQuery<any[]>({
queryKey: ['company-files', filterType],
queryFn: async () => {
const res = await api.get('/company-files', { params: filterType ? { fileType: filterType } : {} }) as any
return res.data || []
},
})
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/company-files', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['company-files'] })
toast.success('文件上传成功')
setRemark('')
setExpiryDate('')
},
onError: () => toast.error('上传失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/company-files/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['company-files'] })
toast.success('已删除')
},
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 10 * 1024 * 1024) {
toast.error('文件不能超过 10MB')
return
}
const reader = new FileReader()
reader.onload = () => {
addMutation.mutate({
fileName: file.name,
fileType,
fileUrl: reader.result as string,
fileSize: file.size,
remark: remark || undefined,
expiryDate: expiryDate || undefined,
})
}
reader.readAsDataURL(file)
e.target.value = ''
}
const fmtSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
<h1 className="text-lg font-semibold"></h1>
</div>
<Card>
<div className="p-4 space-y-3">
<div className="text-sm text-gray-500"></div>
<div className="flex flex-wrap items-end gap-3">
<div>
<Label></Label>
<Select value={fileType} onChange={(e) => setFileType(e.target.value)} className="w-36">
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="如:2024年营业执照" className="w-48" />
</div>
<div>
<Label></Label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-40" />
</div>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button onClick={() => fileInputRef.current?.click()} disabled={addMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{addMutation.isPending ? '上传中...' : '上传文件'}
</Button>
</div>
</div>
</Card>
<Card>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32 text-xs">
<option value=""></option>
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !files || files.length === 0 ? (
<EmptyState title="暂无文件" description="点击上方上传按钮添加公司文件" />
) : (
<div className="space-y-2">
{files.map((f: any) => {
const isExpired = f.expiryDate && new Date(f.expiryDate) < new Date()
const isExpiringSoon = f.expiryDate && !isExpired && (new Date(f.expiryDate).getTime() - new Date().getTime()) < 30 * 24 * 60 * 60 * 1000
return (
<div key={f.id} className="flex items-center gap-3 p-3 rounded-md border hover:bg-gray-50">
<FileText className="w-5 h-5 text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{f.fileName}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{fileTypeLabels[f.fileType] || f.fileType}</span>
{isExpired && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-danger flex items-center gap-0.5"><AlertCircle className="w-3 h-3" /></span>}
{isExpiringSoon && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-warning flex items-center gap-0.5"><Calendar className="w-3 h-3" /></span>}
</div>
<div className="text-xs text-gray-400 mt-0.5">
{fmtSize(f.fileSize)}
{f.remark && <span className="ml-2">· {f.remark}</span>}
{f.expiryDate && <span className="ml-2">· {f.expiryDate.slice(0, 10)}</span>}
<span className="ml-2">· {new Date(f.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
</div>
<a href={f.fileUrl} download={f.fileName} className="text-xs text-primary hover:underline flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
</a>
<button
onClick={() => deleteMutation.mutate(f.id)}
className="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-danger"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)
})}
</div>
)}
</div>
</Card>
</div>
)
}