feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式

This commit is contained in:
freedakgmail
2026-07-27 23:45:22 +08:00
parent 71d7ab2de5
commit 5e22a82163
21 changed files with 1246 additions and 178 deletions
-2
View File
@@ -8,7 +8,6 @@ import MobileTabBar from './components/layout/MobileTabBar'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton'
import OnboardingGuide from './components/OnboardingGuide'
const Login = lazy(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register'))
@@ -62,7 +61,6 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
</div>
</div>
)
+103 -24
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText } from 'lucide-react'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react'
import { toast } from 'sonner'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -351,6 +352,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const { data: employee } = useQuery<any>({
queryKey: ['employee-detail', employeeId],
@@ -379,26 +381,42 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
const files = e.target.files
if (!files || files.length === 0) return
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的文件格式: ${file.name}`)
continue
}
if (file.size > maxSize) {
toast.error(`文件过大: ${file.name}(最大 10MB`)
continue
}
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
}
reader.readAsDataURL(file)
}
reader.readAsDataURL(file)
e.target.value = ''
}
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证',
BANK_CARD: '银行卡',
CONTRACT_SCAN: '合同扫描件',
CONTRACT_SCAN: '合同件',
EDUCATION: '学历证书',
OTHER: '其他',
}
@@ -474,13 +492,14 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="OTHER"></option>
</Select>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
@@ -490,9 +509,10 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
onClick={() => fileInputRef.current?.click()}
disabled={addAttachmentMutation.isPending}
>
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
<p className="text-xs text-gray-400 mb-2"> PDFWordExcel 10MB</p>
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
@@ -501,18 +521,26 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<button onClick={() => setPreviewUrl(att.fileUrl)} className="text-primary hover:underline truncate text-left">
{att.fileName}
</button>
<div className="text-xs text-gray-400">
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger shrink-0 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 shrink-0 ml-2">
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-primary p-1" title="下载">
<Download className="w-4 h-4" />
</a>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger p-1"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
@@ -522,6 +550,57 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
</div>
</div>
</div>
{/* 附件预览弹窗 */}
{previewUrl && (() => {
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 = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<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>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<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" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}
+78 -8
View File
@@ -117,7 +117,7 @@ function BatchManager() {
setShowCreateModal(false)
toast.success('发薪批次已创建')
},
onError: () => toast.error('创建失败'),
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
const deleteBatchMutation = useMutation({
@@ -140,11 +140,26 @@ function BatchManager() {
onError: () => toast.error('重命名失败'),
})
const unarchiveBatchMutation = useMutation({
mutationFn: (batchId: string) => api.post(`/payroll2/batches/${batchId}/unarchive`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
return <BatchDetail batchId={selectedBatchId} onBack={() => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setSelectedBatchId(null)
}} />
}
return (
@@ -190,7 +205,14 @@ function BatchManager() {
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">{batches.length} </span>
)}
<div className="flex-1" />
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }} className="shrink-0">
<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" />
</Button>
</div>
@@ -353,6 +375,21 @@ function BatchManager() {
</button>
</>
)}
{batch.status === 'ARCHIVED' && (
<button
onClick={async () => {
if (await confirm({ title: '取消归档', message: `确认取消归档「${batch.name}」?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。`, variant: 'primary' })) {
unarchiveBatchMutation.mutate(batch.id)
}
}}
disabled={unarchiveBatchMutation.isPending}
className="p-1 rounded hover:bg-amber-50 text-gray-500 hover:text-warning transition-colors"
aria-label="取消归档"
title="取消归档"
>
<Archive className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
@@ -391,12 +428,16 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
const removeEmployeeMutation = useMutation({
mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
},
})
const archiveMutation = useMutation({
@@ -410,10 +451,23 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
},
})
const unarchiveMutation = useMutation({
mutationFn: () => api.post(`/payroll2/batches/${batchId}/unarchive`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
toast.success('已取消归档,批次恢复为草稿状态')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'),
})
const importOvertimeMutation = useMutation({
mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
if (res.data?.imported > 0) {
toast.success(`成功导入 ${res.data.imported} 条加班费记录`)
@@ -443,6 +497,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
setPayrollImportResult(data)
if (data.updated > 0) {
toast.success(`成功更新 ${data.updated} 条工资记录`)
@@ -640,11 +695,25 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</div>
)}
{isArchived && (
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
<div className="flex gap-2">
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
</Button>
</a>
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '取消归档', message: '确认取消归档?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。', variant: 'primary' })) {
unarchiveMutation.mutate()
}
}}
disabled={unarchiveMutation.isPending}
>
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
</Button>
</a>
</div>
)}
</div>
@@ -816,6 +885,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: ()
mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
queryClient.invalidateQueries({ queryKey: ['batches'] })
onClose()
},
})
+9 -1
View File
@@ -13,6 +13,7 @@ import Pagination from '../components/ui/Pagination'
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
import EmployeeProfile from './roster/EmployeeProfile'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
import { ImportSettings } from './Settings'
export default function Roster() {
const queryClient = useQueryClient()
@@ -21,6 +22,7 @@ export default function Roster() {
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
const [showAddModal, setShowAddModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [showResignModal, setShowResignModal] = useState(false)
const [resignEmployee, setResignEmployee] = useState<any>(null)
const [showRehireModal, setShowRehireModal] = useState(false)
@@ -263,7 +265,7 @@ 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={() => window.location.hash = '#/settings'} className="h-9 shrink-0">
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
</div>
@@ -536,6 +538,12 @@ export default function Roster() {
/>
)}
{showImportModal && (
<Modal open={showImportModal} onClose={() => setShowImportModal(false)} title="批量导入" size="lg">
<ImportSettings />
</Modal>
)}
{showResignModal && resignEmployee && (
<ResignModal
employee={resignEmployee}
+1 -1
View File
@@ -775,7 +775,7 @@ function NotificationSettings() {
)
}
function ImportSettings() {
export function ImportSettings() {
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
return (
+115 -14
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } 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 } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -16,6 +16,8 @@ export default function SocialInsurance() {
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
const [city, setCity] = useState<string>('北京')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
const [base, setBase] = useState(8000)
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
const [showNewVersion, setShowNewVersion] = useState(false)
@@ -161,14 +163,14 @@ export default function SocialInsurance() {
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
const res = await api.post('/social/calculate', { base, city }) as any
return res.data
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/housing-calculate', { base }) as any
const res = await api.post('/social/housing-calculate', { base, city }) as any
return res.data
},
})
@@ -193,6 +195,45 @@ export default function SocialInsurance() {
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
const res = await api.post('/social/ai-suggest', vars) as any
return res.data
},
onSuccess: (data) => {
if (isHousing) {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
})
} else {
activeSetNewVersion({
...activeNewVersion,
baseMin: data.baseMin ?? activeNewVersion.baseMin,
baseMax: data.baseMax ?? activeNewVersion.baseMax,
medicalBaseMin: data.medicalBaseMin ?? 0,
medicalBaseMax: data.medicalBaseMax ?? 0,
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
extraInsurances: data.extraInsurances ?? [],
})
}
toast.success('AI建议已填入,请核对后保存')
},
onError: () => {
toast.error('AI建议获取失败,请手动填写')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
@@ -337,16 +378,52 @@ export default function SocialInsurance() {
{(tab === 'social' || tab === 'housing') && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
<input
list="social-cities"
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="输入或选择城市"
/>
<datalist id="social-cities">
{cities.map((c) => <option key={c} value={c} />)}
</datalist>
{showAddCity ? (
<div className="flex items-center gap-1">
<input
className="h-9 w-24 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newCityName}
onChange={(e) => setNewCityName(e.target.value)}
placeholder="城市名"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
}}
/>
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
if (newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
}}></button>
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}></button>
</div>
) : (
<div className="flex items-center gap-1">
<select
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
title="添加新城市"
onClick={() => setShowAddCity(true)}
>
<MapPin className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>
@@ -415,6 +492,17 @@ export default function SocialInsurance() {
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
<div key={idx} className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">{ins.name}(/)</span>
<span className="font-medium">
{ins.baseType === 'fixed'
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
: `${ins.orgRate}% / ${ins.empRate}%`
}
</span>
</div>
))}
</div>
)}
</Card>
@@ -573,9 +661,22 @@ export default function SocialInsurance() {
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>7</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
disabled={aiSuggestMut.isPending}
>
<Sparkles className="w-4 h-4" />
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
</button>
<div className="grid md:grid-cols-3 gap-3">
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
+3 -4
View File
@@ -29,11 +29,10 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
if (!file) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
if (!allowedExts.includes(ext)) {
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
return
}
+3 -4
View File
@@ -30,11 +30,10 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
if (!allowedExts.includes(ext)) {
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
return
}
if (file.size > 10 * 1024 * 1024) {
+142 -36
View File
@@ -12,40 +12,48 @@ import { fmt } from "./shared"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' })
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => api.delete(`/employees/contracts/${contractId}`),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const files = e.target.files
if (!files || files.length === 0) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
// 文件大小校验(10MB
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
return
}
const reader = new FileReader()
reader.onload = (event) => {
setForm({ ...form, attachmentUrl: event.target?.result as string })
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的文件格式: ${file.name}`)
continue
}
if (file.size > maxSize) {
toast.error(`文件过大: ${file.name}(最大 10MB`)
continue
}
const reader = new FileReader()
reader.onload = (event) => {
setForm(prev => ({
...prev,
attachments: [...prev.attachments, { name: file.name, url: event.target?.result as string }],
}))
}
reader.readAsDataURL(file)
}
reader.readAsDataURL(file)
// 清空 input 以便重复选择同一文件
e.target.value = ''
}
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
@@ -102,6 +110,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
probationSalary: 0,
signMethod: 'PAPER',
attachmentUrl: '',
attachments: [],
electronicContractNo: '',
electronicContractUrl: '',
})
@@ -158,14 +167,29 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
<Label> *</Label>
<input ref={contractFileRef} type="file" multiple className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
<Paperclip className="w-4 h-4 mr-1" />
<Paperclip className="w-4 h-4 mr-1" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
{form.attachments.length > 0 && <span className="text-xs text-gray-500">{form.attachments.length} </span>}
</div>
{form.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{form.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button type="button" onClick={() => 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" onClick={() => setForm(prev => ({ ...prev, attachments: prev.attachments.filter((_, i) => i !== idx) }))} className="text-danger hover:underline ml-2 shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-1"> PDFJPGPNGHEICGIFBMPWebPTIFFWordExcel 10MB</p>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
@@ -178,6 +202,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<Button onClick={() => {
const payload = {
...form,
attachmentUrl: form.attachments.length > 0 ? JSON.stringify(form.attachments) : '',
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
@@ -185,7 +210,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
addContractMutation.mutate(payload)
}} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'PAPER' && form.attachments.length === 0) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
@@ -211,15 +236,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
{c.attachmentUrl ? (
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<Paperclip className="w-3 h-3" />
</a>
) : (
<span className="text-gray-400"></span>
)}
<div className="md:col-span-3">
<span className="text-gray-500"></span>
{(() => {
let atts: { name: string; url: string }[] = []
try {
const parsed = JSON.parse(c.attachmentUrl)
atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }]
} catch {
if (c.attachmentUrl) {
const mime = c.attachmentUrl.match(/data:(.*?);/)?.[1] || ''
const ext = mime.split('/')[1] || '文件'
atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => 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>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<Download className="w-3 h-3" />
</a>
</div>
))}
</div>
)
})()}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
@@ -236,9 +282,69 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</>
)}
</div>
<button
onClick={() => { if (confirm('确定删除此合同记录?')) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
{/* 附件预览弹窗 */}
{previewUrl && (() => {
// 将 data URL 转为 blob URL,解决浏览器阻止 iframe 加载 data URL 的问题
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 = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const previewable = isImage || isPdf
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="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>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<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" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}