feat: 附件管理拆为独立tab + 员工端附件上传
管理端: - 花名册详情新增「附件资料」独立tab(人事信息分组下) - 从 BasicInfo 移除附件管理代码到独立 AttachmentsTab 组件 - shared.ts 添加 attachments tab 类型和计数映射 员工端: - 后端新增 GET/POST/DELETE /portal/attachments 接口 - 前端 api-services 添加 portalDelete + 附件 API 方法 - MyProfile 新增附件资料卡片,支持上传/查看/删除附件 - 附件类型:身份证/银行卡/学历证书/职业资格证书/合同/照片/其他
This commit is contained in:
@@ -1027,7 +1027,48 @@ router.put('/profile', portalAuth, async (req: any, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ========== 员工端:离职申请 ==========
|
// ========== 员工端:附件管理 ==========
|
||||||
|
// 查看自己的附件列表
|
||||||
|
router.get('/attachments', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const attachments = await prisma.employeeAttachment.findMany({
|
||||||
|
where: { employeeId, orgId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: attachments })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 上传附件(文件URL由前端 FileReader 转为 base64 后传入)
|
||||||
|
router.post('/attachments', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const { fileName, fileType, fileUrl, fileSize } = req.body
|
||||||
|
if (!fileName || !fileUrl) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '文件名和文件内容不能为空' } })
|
||||||
|
}
|
||||||
|
const attachment = await prisma.employeeAttachment.create({
|
||||||
|
data: { orgId, employeeId, fileName, fileType: fileType || 'OTHER', fileUrl, fileSize: fileSize || 0 },
|
||||||
|
})
|
||||||
|
res.json({ success: true, data: attachment })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除自己的附件
|
||||||
|
router.delete('/attachments/:id', portalAuth, async (req: any, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id: employeeId, orgId } = req.employee
|
||||||
|
const attachment = await prisma.employeeAttachment.findFirst({
|
||||||
|
where: { id: req.params.id, employeeId, orgId },
|
||||||
|
})
|
||||||
|
if (!attachment) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
|
||||||
|
}
|
||||||
|
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
// 提交离职申请
|
// 提交离职申请
|
||||||
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
|
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1130,6 +1130,7 @@ portalAxios.interceptors.response.use(
|
|||||||
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
|
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
|
||||||
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
|
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
|
||||||
const portalPut = ((url: string, data?: any, config?: any) => portalAxios.put(url, data, config)) as any
|
const portalPut = ((url: string, data?: any, config?: any) => portalAxios.put(url, data, config)) as any
|
||||||
|
const portalDelete = ((url: string, config?: any) => portalAxios.delete(url, config)) as any
|
||||||
|
|
||||||
export const portalApi = {
|
export const portalApi = {
|
||||||
/** 登录 */
|
/** 登录 */
|
||||||
@@ -1198,6 +1199,15 @@ export const portalApi = {
|
|||||||
/** 更新个人资料 */
|
/** 更新个人资料 */
|
||||||
updateProfile: (data: Record<string, unknown>) =>
|
updateProfile: (data: Record<string, unknown>) =>
|
||||||
portalPut('/profile', data).then(unwrap<any>()),
|
portalPut('/profile', data).then(unwrap<any>()),
|
||||||
|
/** 获取附件列表 */
|
||||||
|
getAttachments: () =>
|
||||||
|
portalGet('/attachments').then(unwrap<any[]>()),
|
||||||
|
/** 上传附件 */
|
||||||
|
addAttachment: (data: Record<string, unknown>) =>
|
||||||
|
portalPost('/attachments', data).then(unwrap<any>()),
|
||||||
|
/** 删除附件 */
|
||||||
|
removeAttachment: (id: string) =>
|
||||||
|
portalDelete(`/attachments/${id}`),
|
||||||
/** 制度列表 */
|
/** 制度列表 */
|
||||||
policies: () =>
|
policies: () =>
|
||||||
portalGet('/policies').then(unwrap<any[]>()),
|
portalGet('/policies').then(unwrap<any[]>()),
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* 员工可查看自己的入职资料,并编辑部分字段(紧急联系人、地址、银行卡、学历等)
|
* 员工可查看自己的入职资料,并编辑部分字段(紧急联系人、地址、银行卡、学历等)
|
||||||
* 数据直接同步到花名册(Employee 表)
|
* 数据直接同步到花名册(Employee 表)
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { User, Phone, IdCard, MapPin, Banknote, GraduationCap, AlertCircle, Check, Save, Briefcase, Calendar } from 'lucide-react'
|
import { User, Phone, IdCard, MapPin, Banknote, GraduationCap, AlertCircle, Check, Save, Briefcase, Calendar, Paperclip, Trash2, Eye, Upload } from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
import Button from '../../components/ui/Button'
|
import Button from '../../components/ui/Button'
|
||||||
@@ -307,6 +307,136 @@ export default function MyProfile() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 附件资料卡片 */}
|
||||||
|
<AttachmentCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 附件资料卡片 — 员工可上传/查看/删除自己的附件 */
|
||||||
|
function AttachmentCard() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
|
||||||
|
|
||||||
|
const { data: attachments = [], isLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['portal-attachments'],
|
||||||
|
queryFn: () => portalApi.getAttachments(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => portalApi.addAttachment(data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['portal-attachments'] }),
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '上传失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => portalApi.removeAttachment(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['portal-attachments'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx']
|
||||||
|
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||||
|
if (!allowedExts.includes(ext)) {
|
||||||
|
toast.error('不支持的文件格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error('文件过大,请上传小于 10MB 的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const fileUrl = event.target?.result as string
|
||||||
|
addMutation.mutate({ fileName: file.name, fileType, fileUrl, fileSize: file.size })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileTypeLabels: Record<string, string> = {
|
||||||
|
ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书',
|
||||||
|
CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他',
|
||||||
|
}
|
||||||
|
const fileTypeColors: Record<string, string> = {
|
||||||
|
ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe',
|
||||||
|
EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600',
|
||||||
|
CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500',
|
||||||
|
}
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (!bytes) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Paperclip className="w-4 h-4 text-primary" />
|
||||||
|
<h2 className="text-sm font-medium">附件资料({attachments.length}个)</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center mb-3">
|
||||||
|
<select
|
||||||
|
value={fileType}
|
||||||
|
onChange={(e) => setFileType(e.target.value as any)}
|
||||||
|
className="h-9 px-2 rounded-md border border-gray-300 text-xs"
|
||||||
|
>
|
||||||
|
<option value="ID_CARD">身份证</option>
|
||||||
|
<option value="BANK_CARD">银行卡</option>
|
||||||
|
<option value="EDUCATION">学历证书</option>
|
||||||
|
<option value="CERTIFICATE">职业资格证书</option>
|
||||||
|
<option value="CONTRACT">合同扫描件</option>
|
||||||
|
<option value="PHOTO">员工照片</option>
|
||||||
|
<option value="OTHER">其他</option>
|
||||||
|
</select>
|
||||||
|
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addMutation.isPending}>
|
||||||
|
<Upload className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{addMutation.isPending ? '上传中...' : '上传附件'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-gray-400 text-xs">加载中...</div>
|
||||||
|
) : attachments.length > 0 ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||||||
|
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||||
|
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`确认删除「${att.fileName}」?`)) deleteMutation.mutate(att.id)
|
||||||
|
}}
|
||||||
|
className="text-gray-300 hover:text-red-500"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-400 text-xs text-center py-4">暂无附件,请点击上方上传</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { attachmentApi } from '../../lib/api-services'
|
||||||
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
|
import Card from '../../components/ui/Card'
|
||||||
|
import Button from '../../components/ui/Button'
|
||||||
|
import { Select } from '../../components/ui/Input'
|
||||||
|
import { Paperclip, Trash2, Eye, Download } from 'lucide-react'
|
||||||
|
|
||||||
|
const fileTypeLabels: Record<string, string> = {
|
||||||
|
ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书',
|
||||||
|
CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', CONTRACT_SCAN: '合同扫描件',
|
||||||
|
PHOTO: '员工照片', OTHER: '其他',
|
||||||
|
TERMINATION_DOC: '离职文件', RETIREMENT_DOC: '退休文件',
|
||||||
|
INJURY_CERT: '工伤证明', MEDICAL_CERT: '医疗证明', PREGNANCY_CERT: '孕期证明',
|
||||||
|
DISCIPLINARY: '违纪文件',
|
||||||
|
}
|
||||||
|
const fileTypeColors: Record<string, string> = {
|
||||||
|
ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe',
|
||||||
|
EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600',
|
||||||
|
CONTRACT: 'bg-cyan-50 text-cyan-600', CONTRACT_SCAN: 'bg-cyan-50 text-cyan-600',
|
||||||
|
PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500',
|
||||||
|
TERMINATION_DOC: 'bg-red-50 text-red-600', RETIREMENT_DOC: 'bg-orange-50 text-orange-600',
|
||||||
|
INJURY_CERT: 'bg-red-50 text-red-600', MEDICAL_CERT: 'bg-orange-50 text-orange-600',
|
||||||
|
PREGNANCY_CERT: 'bg-pink-50 text-pink-600', DISCIPLINARY: 'bg-red-50 text-red-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (!bytes) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttachmentsTab({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
|
||||||
|
|
||||||
|
const addAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => attachmentApi.add(data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAttachmentMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => attachmentApi.remove(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
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 (!allowedExts.includes(ext)) {
|
||||||
|
toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error('文件过大,请上传小于 10MB 的文件')
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (att: any) => {
|
||||||
|
const ok = await confirm({ title: '删除附件', message: `确认删除「${att.fileName}」?` })
|
||||||
|
if (ok) deleteAttachmentMutation.mutate(att.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xs font-medium">附件资料({attachments?.length || 0}个)</h2>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
|
||||||
|
<option value="ID_CARD">身份证</option>
|
||||||
|
<option value="BANK_CARD">银行卡</option>
|
||||||
|
<option value="EDUCATION">学历证书</option>
|
||||||
|
<option value="CERTIFICATE">职业资格证书</option>
|
||||||
|
<option value="CONTRACT">合同扫描件</option>
|
||||||
|
<option value="PHOTO">员工照片</option>
|
||||||
|
<option value="OTHER">其他</option>
|
||||||
|
</Select>
|
||||||
|
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
||||||
|
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachments?.length ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||||||
|
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||||
|
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
<button onClick={() => handleDelete(att)} className="text-gray-300 hover:text-danger" title="删除">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-400 text-xs text-center py-8">暂无附件,请点击右上角上传</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -563,52 +563,6 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 附件管理 */}
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xs font-medium text-gray-600">附件管理({attachments?.length || 0}个)</h3>
|
|
||||||
{!editing && (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
|
|
||||||
<option value="ID_CARD">身份证</option><option value="BANK_CARD">银行卡</option><option value="EDUCATION">学历证书</option><option value="CERTIFICATE">职业资格证书</option><option value="CONTRACT">合同扫描件</option><option value="PHOTO">员工照片</option><option value="OTHER">其他</option>
|
|
||||||
</Select>
|
|
||||||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
|
||||||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!editing && attachments?.length ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{attachments.map((att) => (
|
|
||||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
|
||||||
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
|
||||||
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : !editing ? <div className="text-gray-400 text-xs text-center py-3">暂无附件</div> : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import PerformanceInfo from './PerformanceInfo'
|
|||||||
import TerminationInfo from './TerminationInfo'
|
import TerminationInfo from './TerminationInfo'
|
||||||
import ChangeHistoryTab from './ChangeHistoryTab'
|
import ChangeHistoryTab from './ChangeHistoryTab'
|
||||||
import EvidenceChain from './EvidenceChain'
|
import EvidenceChain from './EvidenceChain'
|
||||||
|
import AttachmentsTab from './AttachmentsTab'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 员工详情档案页 — 使用 EmployeeProfileShell 壳组件统一布局
|
* 员工详情档案页 — 使用 EmployeeProfileShell 壳组件统一布局
|
||||||
@@ -62,6 +63,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
|||||||
{(activeTab) => (
|
{(activeTab) => (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||||
|
{activeTab === 'attachments' && <AttachmentsTab employeeId={employeeId} attachments={profile.attachments} />}
|
||||||
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} employee={profile} />}
|
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} employee={profile} />}
|
||||||
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
|
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} employeeId={employeeId} />}
|
||||||
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const terminateReasonMap: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 详情页 Tab 类型 */
|
/** 详情页 Tab 类型 */
|
||||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history'
|
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history' | 'attachments'
|
||||||
|
|
||||||
/** Tab 分组 */
|
/** Tab 分组 */
|
||||||
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
|
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
|
||||||
@@ -25,6 +25,7 @@ export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: strin
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ key: 'basic', label: '基本信息', icon: null },
|
{ key: 'basic', label: '基本信息', icon: null },
|
||||||
{ key: 'contract', label: '劳动合同', icon: null },
|
{ key: 'contract', label: '劳动合同', icon: null },
|
||||||
|
{ key: 'attachments', label: '附件资料', icon: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,4 +65,5 @@ export const TAB_COUNT_KEYS: Record<string, string> = {
|
|||||||
disciplinary: 'disciplinaryRecords',
|
disciplinary: 'disciplinaryRecords',
|
||||||
performance: 'performanceRecords',
|
performance: 'performanceRecords',
|
||||||
termination: 'terminations',
|
termination: 'terminations',
|
||||||
|
attachments: 'attachments',
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user