feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
@@ -0,0 +1,112 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
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 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 maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
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 fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', 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', 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 (
<div className="space-y-3">
<h2 className="text-xs font-medium">{attachments?.length || 0}</h2>
<Card>
<div className="flex gap-2 mb-3 flex-nowrap items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></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>
<span className="text-gray-400 text-xs"> PDF/JPG/PNG 10MB</span>
</div>
{attachments?.length ? (
<div className="space-y-2">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2.5 text-xs hover:bg-gray-100">
<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 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>
<span className="text-gray-400">{new Date(att.createdAt).toLocaleDateString('zh-CN')}</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-4 h-4" />
</button>
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
<Download className="w-4 h-4" />
</a>
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
) : <div className="text-gray-400 text-xs text-center py-4"></div>}
</Card>
</div>
)
}
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 考勤/加班/培训合并组件 */
export default function AttendanceOvertimeInfo({ employeeId, attendanceRecords, overtimeRecords, trainingRecords }: { employeeId: string; attendanceRecords: any[]; overtimeRecords: any[]; trainingRecords: any[] }) {
const queryClient = useQueryClient()
const [subTab, setSubTab] = useState<'attendance' | 'overtime' | 'training'>('attendance')
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' })
const [trainingForm, setTrainingForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const createTrainingMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteTrainingMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const statusMap: Record<string, string> = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
const statusColor: Record<string, string> = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' }
const totalPay = (overtimeRecords || []).reduce((sum, o) => sum + (o.totalPay || 0), 0)
const totalWeekday = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekdayHours || 0), 0)
const totalWeekend = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekendHours || 0), 0)
const totalHoliday = (overtimeRecords || []).reduce((sum, o) => sum + (o.holidayHours || 0), 0)
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex gap-1">
<button
onClick={() => setSubTab('attendance')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'attendance' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{attendanceRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('overtime')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'overtime' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{overtimeRecords?.length || 0}
</button>
<button
onClick={() => setSubTab('training')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
subTab === 'training' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{trainingRecords?.length || 0}
</button>
</div>
{subTab === 'attendance' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
{subTab === 'training' && (
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
)}
</div>
{subTab === 'attendance' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
<div><Label></Label>
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input type="time" value={form.checkInTime} onChange={(e) => setForm({ ...form, checkInTime: e.target.value })} /></div>
<div><Label>退</Label><Input type="time" value={form.checkOutTime} onChange={(e) => setForm({ ...form, checkOutTime: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.lateMinutes} onChange={(e) => setForm({ ...form, lateMinutes: Number(e.target.value) })} /></div>
<div><Label>退</Label><Input type="number" value={form.earlyMinutes} onChange={(e) => setForm({ ...form, earlyMinutes: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.workHours} onChange={(e) => setForm({ ...form, workHours: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.overtimeHours} onChange={(e) => setForm({ ...form, overtimeHours: Number(e.target.value) })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{attendanceRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left">退</th>
<th className="py-2 text-center"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{attendanceRecords?.map((a) => (
<tr key={a.id} className="border-b last:border-0">
<td className="py-2">{a.date?.toString().slice(0, 10)}</td>
<td className="py-2 text-gray-500">{a.checkInTime || '-'}</td>
<td className="py-2 text-gray-500">{a.checkOutTime || '-'}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${statusColor[a.status] || 'bg-gray-100'}`}>{statusMap[a.status] || a.status}</span></td>
<td className="py-2 text-right">{a.workHours}h</td>
<td className="py-2 text-right">{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}</td>
<td className="py-2"><button onClick={() => deleteMutation.mutate(a.id)} className="text-xs text-gray-400 hover:text-danger"></button></td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'overtime' && (
<>
{overtimeRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{overtimeRecords.map((o) => (
<tr key={o.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{o.month}</td>
<td className="py-2 text-right text-gray-600">{o.weekdayHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.weekendHours || '-'}</td>
<td className="py-2 text-right text-gray-600">{o.holidayHours || '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(o.totalPay)}</td>
</tr>
))}
<tr className="border-t-2 bg-gray-50">
<td className="py-2 font-medium"></td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekday}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalWeekend}</td>
<td className="py-2 text-right font-medium text-gray-600">{totalHoliday}</td>
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
</tr>
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'training' && (
<>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={trainingForm.trainingDate} onChange={(e) => setTrainingForm({ ...trainingForm, trainingDate: e.target.value })} /></div>
<div><Label>/</Label><Input value={trainingForm.topic} onChange={(e) => setTrainingForm({ ...trainingForm, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.content} onChange={(e) => setTrainingForm({ ...trainingForm, content: e.target.value })} /></div>
<div><Label></Label><Input value={trainingForm.trainer} onChange={(e) => setTrainingForm({ ...trainingForm, trainer: e.target.value })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={trainingForm.duration} onChange={(e) => setTrainingForm({ ...trainingForm, duration: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={trainingForm.ackStatus} onChange={(e) => setTrainingForm({ ...trainingForm, ackStatus: e.target.value })}>
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
{trainingForm.ackStatus === 'SIGNED' && <div><Label></Label><Input type="date" value={trainingForm.ackDate} onChange={(e) => setTrainingForm({ ...trainingForm, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2"><Label></Label><Input value={trainingForm.remark} onChange={(e) => setTrainingForm({ ...trainingForm, remark: e.target.value })} /></div>
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createTrainingMutation.mutate(trainingForm)} disabled={createTrainingMutation.isPending || !trainingForm.trainingDate || !trainingForm.topic}>{createTrainingMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{trainingRecords?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : trainingRecords?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400">{r.trainingDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.ackStatus === 'SIGNED' ? 'bg-green-50 text-safe' : r.ackStatus === 'REFUSED' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-600'}`}>
{r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'}
</span>
</div>
<div className="text-xs font-medium text-gray-700">{r.topic}</div>
{r.content && <div className="text-xs text-gray-500 leading-relaxed">{r.content}</div>}
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
<span> {r.duration}h</span>
{r.trainer && <span>{r.trainer}</span>}
{r.ackDate && <span className="text-safe">{r.ackDate.toString().slice(0, 10)}</span>}
{r.remark && <span className="text-gray-400">{r.remark}</span>}
</div>
</div>
<button onClick={() => deleteTrainingMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</>
)}
</div>
)
}
+395
View File
@@ -0,0 +1,395 @@
import { QRCodeSVG } from "qrcode.react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
})
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 ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
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 fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', 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', 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`
}
const [form, setForm] = useState({
department: profile.department || '',
gender: profile.gender || '男',
femaleWorkerType: profile.femaleWorkerType || '',
phone: profile.phone || '',
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
monthlySalary: profile.monthlySalary || '',
emergencyContact: profile.emergencyContact || '',
emergencyPhone: profile.emergencyPhone || '',
address: profile.address || '',
bankName: profile.bankName || '',
bankAccount: profile.bankAccount || '',
isPregnant: profile.isPregnant || false,
isInMedicalPeriod: profile.isInMedicalPeriod || false,
isWorkInjured: profile.isWorkInjured || false,
socialInsBase: profile.socialInsBase ?? '',
housingFundBase: profile.housingFundBase ?? '',
specialDeduction: profile.specialDeduction ?? 0,
city: profile.city || '',
cityChangeReason: '',
})
const updateMutation = useMutation({
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setEditing(false)
},
})
const handleSave = () => {
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
toast.error('参保城市变更必须填写变更原因')
return
}
const data: any = {
department: form.department,
gender: form.gender,
femaleWorkerType: form.femaleWorkerType || undefined,
phone: form.phone || undefined,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: String(form.monthlySalary),
emergencyContact: form.emergencyContact || undefined,
emergencyPhone: form.emergencyPhone || undefined,
address: form.address || undefined,
bankName: form.bankName || undefined,
bankAccount: form.bankAccount || undefined,
isPregnant: form.isPregnant,
isInMedicalPeriod: form.isInMedicalPeriod,
isWorkInjured: form.isWorkInjured,
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
specialDeduction: Number(form.specialDeduction) || 0,
city: form.city || undefined,
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
}
updateMutation.mutate(data)
}
const personalFields = [
{ label: '姓名', value: profile.name },
{ label: '部门', value: profile.department },
{ label: '性别', value: profile.gender || '未填写' },
...(profile.gender === '女'
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
: []),
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
...(profile.retirementDaysLeft != null
? (() => {
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
if (profile.birthDate) {
const bd = new Date(profile.birthDate)
const gender = profile.gender || '男'
const fwt = profile.femaleWorkerType || null
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
const baseRetireDate = new Date(bd)
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
const reformStart = new Date(2025, 0, 1)
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
const retireDate = new Date(baseRetireDate)
retireDate.setMonth(retireDate.getMonth() + delayMonths)
return [{ label: '退休日期', value: `${retireDate.getFullYear()}${retireDate.getMonth() + 1}${retireDate.getDate()}` }]
}
return [{ label: '距退休', value: `${profile.retirementDaysLeft}` }]
})()
: []),
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
? [{ label: '离职日期', value: profile.terminations
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
.filter(Boolean)
.sort()
.reverse()[0] || '未记录' }]
: []),
]
const salaryFields = [
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
{ label: '住址', value: profile.address || '未填写' },
{ label: '开户行', value: profile.bankName || '未填写' },
{ label: '银行账号', value: profile.bankAccount || '未填写' },
]
const special = [
{ label: '孕期', value: profile.isPregnant },
{ label: '医疗期', value: profile.isInMedicalPeriod },
{ label: '工伤', value: profile.isWorkInjured },
]
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xs font-medium"></h2>
{!editing ? (
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}></Button>
) : (
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}></Button>
</div>
)}
</div>
{!editing ? (
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
{personalFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
</div>
))}
</div>
</div>
<div className="pt-3 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
{salaryFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
</div>
))}
</div>
</div>
</div>
) : (
<div className="grid md:grid-cols-2 gap-3">
<div><Label></Label><Input value={profile.name} disabled /></div>
<div><Label></Label><Input value={profile.idCardNumber || ''} disabled /></div>
<div><Label></Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
<div><Label></Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男"></option><option value="女"></option></Select></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
)}
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
<div><Label></Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
<div><Label></Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" /></div>
<div><Label></Label><Input value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} placeholder="选填" /></div>
</div>
)}
{/* 薪税信息 */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="grid md:grid-cols-4 gap-4">
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.city || '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
<div>
<Label></Label>
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
</div>
<div>
<Label>/</Label>
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
</div>
{form.city !== (profile.city || '') && (
<div className="md:col-span-4">
<Label></Label>
<Input placeholder="如:员工从北京调往上海工作" value={form.cityChangeReason} onChange={(e) => setForm({ ...form, cityChangeReason: e.target.value })} />
</div>
)}
</div>
)}
<p className="text-xs text-gray-400 mt-2">/7portal端填报0</p>
</div>
{/* 特殊状态 */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="flex gap-4">
{special.map((s) => (
<span key={s.label} className={`px-3 py-1 rounded text-xs ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
{s.label}{s.value ? '是' : '否'}
</span>
))}
</div>
) : (
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
</label>
</div>
)}
{(profile.isPregnant || profile.isInMedicalPeriod || profile.isWorkInjured) && !editing && (
<p className="text-xs text-amber-600 mt-2"> </p>
)}
</div>
{/* 员工端二维码 */}
{!editing && profile.phone && (
<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"></h3>
<Button size="sm" variant="secondary" onClick={() => {
const url = `${window.location.origin}/portal/login`
navigator.clipboard?.writeText(url)
}}>
</Button>
</div>
<div className="flex items-center gap-4">
<div className="bg-white p-3 rounded-lg border">
<QRCodeSVG
value={`${window.location.origin}/portal/login`}
size={120}
level="M"
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p>使</p>
<p></p>
<p className="text-gray-400">{window.location.origin}/portal/login</p>
</div>
</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="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>
)
}
@@ -0,0 +1,140 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt, terminateReasonMap } from "./shared"
import { CityHistoryTab } from "./PayslipSocialInfo"
/** 变更历史Tab:分组显示各类变更记录 */
export default function ChangeHistoryTab({ profile }: { profile: any }) {
const salaryChanges = profile.salaryChanges || []
const departmentRecords = profile.departmentRecords || []
const socialInsRecords = profile.socialInsRecords || []
const housingFundRecords = profile.housingFundRecords || []
const terminations = profile.terminations || []
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', SALARY_CHANGE: '调薪', TRANSFER: '调部门', CITY_CHANGE: '城市变更' }
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', SALARY_CHANGE: 'bg-indigo-50 text-indigo-600', TRANSFER: 'bg-purple-50 text-purple-600', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
const totalChanges = salaryChanges.length + departmentRecords.length + socialInsRecords.length + housingFundRecords.length + terminations.length
if (totalChanges === 0) {
return <Card><div className="text-center py-8 text-gray-400"></div></Card>
}
return (
<div className="space-y-4">
{/* 薪资变更 */}
{salaryChanges.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-indigo-600" />
{salaryChanges.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{salaryChanges.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{new Date(r.effectiveDate).toLocaleDateString('zh-CN')}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.oldSalary)}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.newSalary)}</td>
<td className="py-2 text-right font-medium text-primary">{r.newSalary >= r.oldSalary ? '+' : ''}¥{fmt(r.newSalary - r.oldSalary)}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* 部门变更 */}
{departmentRecords.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4 text-purple-600" />
{departmentRecords.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{departmentRecords.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.effectiveMonth}</td>
<td className="py-2 text-gray-600">{r.oldDepartment || '无'}</td>
<td className="py-2 font-medium">{r.newDepartment}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* 参保城市变更(社保+公积金合并) */}
{(socialInsRecords.length > 0 || housingFundRecords.length > 0) && (
<CityHistoryTab socialInsRecords={socialInsRecords} housingFundRecords={housingFundRecords} changeTypeMap={changeTypeMap} changeTypeColor={changeTypeColor} />
)}
{/* 离职/解聘记录 */}
{terminations.length > 0 && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<UserX className="w-4 h-4 text-danger" />
/{terminations.length}
</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{terminations.map((r: any, idx: number) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{new Date(r.terminationDate).toLocaleDateString('zh-CN')}</td>
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded text-xs bg-red-50 text-danger">{terminateReasonMap[r.terminationType] || r.terminationType}</span></td>
<td className="py-2 text-gray-600 text-xs">{r.reason || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-2 text-gray-400 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
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 contractFileRef = useRef<HTMLInputElement>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const handleContractFileUpload = (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 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 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 })
}
reader.readAsDataURL(file)
}
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
// 按劳动合同法自动判断续签和合同类型建议
const contractAdvice = (() => {
if (!contracts?.length) return null
const fixedContracts = contracts.filter((c: any) => c.contractType === 'FIXED')
const latestContract = contracts[0]
const isRenewal = !!latestContract?.endDate
const renewalCount = (latestContract?.renewalCount || 0)
// 连续订立二次固定期限劳动合同,第三次应订立无固定期限
const shouldUnfixed = fixedContracts.length >= 2
// 连续工作满十年
const yearsSinceHire = hireDate ? (Date.now() - new Date(hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000) : 0
const shouldUnfixedByTenure = yearsSinceHire >= 10
if (shouldUnfixed || shouldUnfixedByTenure) {
return {
isRenewal,
renewalCount: isRenewal ? renewalCount + 1 : renewalCount,
suggestedType: 'UNFIXED',
reason: shouldUnfixed
? `已连续签订${fixedContracts.length}次固定期限合同,按《劳动合同法》第十四条应订立无固定期限合同`
: `连续工作满${Math.floor(yearsSinceHire)}年,按《劳动合同法》第十四条应订立无固定期限合同`,
}
}
if (isRenewal) {
return {
isRenewal: true,
renewalCount: renewalCount + 1,
suggestedType: 'FIXED',
reason: `本次为第${renewalCount + 1}次续签`,
}
}
return null
})()
const handleShowForm = () => {
if (contractAdvice?.suggestedType) {
setForm({
...form,
contractType: contractAdvice.suggestedType,
signDate: new Date().toISOString().slice(0, 10),
startDate: contractAdvice.isRenewal && contracts[0]?.endDate
? contracts[0].endDate.toString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
endDate: '',
probationMonths: 0,
probationSalary: 0,
signMethod: 'PAPER',
attachmentUrl: '',
electronicContractNo: '',
electronicContractUrl: '',
})
}
setShowForm(!showForm)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{contracts?.length || 0}</h2>
<Button size="sm" onClick={handleShowForm}></Button>
</div>
{contractAdvice && !showForm && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}</span>
</div>
)}
{showForm && (
<Card>
{contractAdvice && (
<div className="px-3 py-2 mb-3 rounded-md bg-amber-50 text-amber-700 text-xs flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}</span>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
</Select>
</div>
<div><Label></Label><Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} /></div>
<div><Label> *</Label><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></div>
{form.contractType === 'FIXED' && (
<div><Label></Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
)}
{form.contractType === 'FIXED' && !contractAdvice?.isRenewal && (
<>
<div><Label>()</Label><Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} /></div>
<div><Label></Label><Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} /></div>
</>
)}
<div className="md:col-span-2 border-t pt-3">
<Label></Label>
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
<option value="PAPER"></option>
<option value="ELECTRONIC"></option>
</Select>
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" 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" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
</div>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
<>
<div><Label> *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
<div><Label> *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
</>
)}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => addContractMutation.mutate(form)} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" onClick={() => setShowForm(false)}></Button>
</div>
</div>
</Card>
)}
{!contracts?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : contracts.map((c) => (
<Card key={c.id}>
<div className="flex items-start justify-between">
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 flex-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{typeMap[c.contractType] || c.contractType}</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.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</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.startDate?.toString().slice(0, 10)}</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.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</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.contractYears}</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.probationMonths}¥{c.probationSalary}</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.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>
)}
{c.signMethod === 'ELECTRONIC' && (
<>
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.electronicContractNo}</span></div>}
{c.electronicContractUrl && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<FileText className="w-3 h-3" />
</a>
</div>
)}
</>
)}
</div>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,116 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 违纪记录管理 ==========
export default function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/disciplinary`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/disciplinary/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{records?.length || 0}</h2>
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
</div>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input type="date" value={form.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} /></div>
<div><Label></Label>
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
{Object.entries(typeMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div className="md:col-span-2"><Label></Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" /></div>
<div><Label></Label>
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
{Object.entries(severityMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label>
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div><Label></Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="扣款金额/降职说明等" /></div>
<div><Label></Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="empAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="empAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.violationDate || !form.description}>
{createMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" onClick={() => setShowForm(false)}></Button>
</div>
</div>
</Card>
)}
{records?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : records?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{r.violationDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.severity === 'SEVERE' ? 'bg-red-100 text-red-700' : r.severity === 'SERIOUS' ? 'bg-orange-50 text-orange-600' : 'bg-amber-50 text-amber-600'}`}>
{severityMap[r.severity] || r.severity}
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{typeMap[r.violationType] || r.violationType}</span>
</div>
<div className="text-xs text-gray-600 leading-relaxed">{r.description}</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-gray-400"></span>
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-600">{actionMap[r.action] || r.action}</span>
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
</div>
<div className="flex items-center gap-4 text-xs pt-1 border-t">
{r.employeeAck ? (
<span className="text-safe flex items-center gap-1">
<Check className="w-3 h-3" />{r.ackDate?.toString().slice(0, 10)}
</span>
) : (
<span className="text-warning flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
</span>
)}
{r.witness && <span className="text-gray-400">{r.witness}</span>}
{r.ackMethod && <span className="text-gray-400">{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,106 @@
/** EmployeeProfile 组件 - 员工详情页 */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { X } from 'lucide-react'
import api from '../../lib/api'
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
import BasicInfo from './BasicInfo'
import ContractInfo from './ContractInfo'
import PayslipSocialInfo from './PayslipSocialInfo'
import DisciplinaryInfo from './DisciplinaryInfo'
import AttendanceOvertimeInfo from './AttendanceOvertimeInfo'
import PerformanceInfo from './PerformanceInfo'
import TerminationInfo from './TerminationInfo'
import ChangeHistoryTab from './ChangeHistoryTab'
/**
* 员工详情档案页
*/
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
const [tab, setTab] = useState<DetailTab>('basic')
const { data: profile, isLoading } = useQuery<any>({
queryKey: ['roster-profile', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/profile`) as any
return res.data
},
})
const isActive = profile?.status === 'ACTIVE'
const HIDDEN_FOR_RESIGNED: DetailTab[] = ['attendance', 'performance']
const getTabCount = (key: DetailTab): number => {
const dataKey = TAB_COUNT_KEYS[key]
if (!dataKey || !profile) return 0
const data = (profile as any)[dataKey]
if (!Array.isArray(data)) return 0
if (key === 'payslip') {
const monthly = (profile as any).monthlyProcessRecords
return data.length + (Array.isArray(monthly) ? monthly.length : 0)
}
if (key === 'attendance') {
const training = (profile as any).trainingRecords
return data.length + (Array.isArray(training) ? training.length : 0)
}
return data.length
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!profile) return <div className="text-center py-8 text-gray-400"></div>
return (
<div className="flex flex-col h-[calc(100vh-120px)]">
<div className="flex items-center gap-3 shrink-0 pb-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
<h1 className="text-xs font-medium">{profile.name} - </h1>
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{profile.status === 'ACTIVE' ? '在职' : '离职'}
</span>
</div>
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
{TAB_GROUPS.map((group) => (
<div key={group.group} className="flex items-center">
{group.tabs
.filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key))
.map((t) => {
const count = getTabCount(t.key)
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t.label}
{count > 0 && (
<span className={`ml-0.5 px-1.5 py-0.5 rounded-full text-[10px] leading-none ${
tab === t.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'
}`}>
{count}
</span>
)}
</button>
)
})}
</div>
))}
</div>
<div className="flex-1 overflow-y-auto pt-3">
{tab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
{tab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
</div>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
})
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
const categoryColor: Record<string, string> = {
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
'薪酬发放': 'bg-green-50 text-green-700 border-green-200',
'考勤记录': 'bg-amber-50 text-amber-700 border-amber-200',
'违纪处理': 'bg-red-50 text-red-700 border-red-200',
'培训签收': 'bg-purple-50 text-purple-700 border-purple-200',
'绩效考核': 'bg-indigo-50 text-indigo-700 border-indigo-200',
'解聘记录': '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 riskStyle: Record<string, string> = {
DANGER: 'bg-red-50 border-red-300 text-red-700',
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
}
const riskIcon: Record<string, string> = {
DANGER: '🔴',
HIGH: '🟠',
MEDIUM: '🟡',
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" /></h2>
<div className="text-xs text-gray-500 mt-1">
{data.employee.name} · {data.employee.department} · {data.employee.hireDate}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold">{data.summary.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
</div>
{data.summary.riskCount > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
</div>
)}
<Button onClick={handleExport}></Button>
</div>
</div>
</Card>
{data.risks && data.risks.length > 0 && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-danger" />
{data.risks.length}
</h3>
<div className="space-y-2">
{data.risks.map((r: any, i: number) => (
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
<div className="flex items-center gap-2">
<span>{riskIcon[r.level] || '⚠'}</span>
<span className="font-medium text-xs">{r.title}</span>
<span className="text-xs opacity-70">{r.category}</span>
</div>
<div className="text-xs mt-1 opacity-90">{r.description}</div>
</div>
))}
</div>
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
<div className="flex items-start gap-3">
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
{e.category}
</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-xs">{e.title}</span>
<span className="text-xs text-gray-400">{e.date}</span>
{e.acknowledged === true && <span className="text-xs text-safe"> </span>}
{e.acknowledged === false && <span className="text-xs text-warning"> </span>}
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger"> </span>}
</div>
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
export function generateEvidenceText(data: any): string {
const lines: string[] = []
lines.push('========================================')
lines.push(' 劳动仲裁证据链')
lines.push('========================================')
lines.push('')
lines.push(`员工姓名:${data.employee.name}`)
lines.push(`部门:${data.employee.department}`)
lines.push(`入职日期:${data.employee.hireDate}`)
lines.push(`状态:${data.employee.status === 'ACTIVE' ? '在职' : '离职'}`)
lines.push('')
lines.push(`证据总数:${data.summary.total}`)
lines.push(`已签字:${data.summary.signed}`)
lines.push(`未签字:${data.summary.unsigned}`)
lines.push('')
lines.push('----------------------------------------')
lines.push('')
let currentCategory = ''
data.evidence.forEach((e: any, i: number) => {
if (e.category !== currentCategory) {
currentCategory = e.category
lines.push(`${currentCategory}`)
lines.push('')
}
lines.push(`${i + 1}. ${e.title}${e.date}`)
lines.push(` ${e.description}`)
if (e.acknowledged === true) lines.push(' [已签字确认]')
if (e.acknowledged === false) lines.push(' [未签字]')
lines.push('')
})
lines.push('----------------------------------------')
lines.push(`导出时间:${new Date().toLocaleString('zh-CN')}`)
return lines.join('\n')
}
@@ -0,0 +1,282 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' }
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
return (
<div className="space-y-3">
<div className="flex gap-1">
<button
onClick={() => setSubTab('payslip')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{payslips?.length || 0}
</button>
<button
onClick={() => setSubTab('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{monthlyProcessRecords?.length || 0}
</button>
</div>
{subTab === 'payslip' && (
<>
{!payslips?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{payslips.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{p.month}</td>
<td className="py-2 text-right text-gray-600">¥{fmt(p.baseSalary)}</td>
<td className="py-2 text-right text-gray-600">{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}</td>
<td className="py-2 text-right text-gray-600">{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}</td>
<td className="py-2 text-right text-gray-600">{p.deduction > 0 ? `${fmt(p.deduction)}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(p.totalPay)}</td>
<td className="py-2 text-center">
{p.confirmedAt ? (
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs"></span>
) : (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs"></span>
)}
</td>
</tr>
))}
<tr className="border-t-2 bg-gray-50">
<td className="py-2 font-medium"></td>
<td className="py-2 text-right font-medium text-gray-600">¥{fmt(payslips.reduce((s, p) => s + (p.baseSalary || 0), 0))}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.overtimePay || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.overtimePay || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.allowance || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.allowance || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.deduction || 0), 0) > 0 ? `${fmt(payslips.reduce((s, p) => s + (p.deduction || 0), 0))}` : '-'}</td>
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(payslips.reduce((s, p) => s + (p.totalPay || 0), 0))}</td>
<td></td>
</tr>
</tbody>
</table>
</Card>
)}
</>
)}
{subTab === 'monthly' && (
<>
{!monthlyProcessRecords?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{monthlyProcessRecords.map((r, idx) => {
const d = r.detail
const isSocial = r.type === 'SOCIAL'
const orgAmt = isSocial ? d?.totalOrg : d?.orgAmount
const empAmt = isSocial ? d?.totalEmp : d?.empAmount
const total = isSocial ? d?.total : d?.total
return (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.month}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${isSocial ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{isSocial ? '社保' : '公积金'}</span></td>
<td className="py-2 text-center text-xs text-gray-600">{r.city || '-'}</td>
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs"></span></td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
<td className="py-2 text-right text-danger">{orgAmt != null ? `¥${fmt(orgAmt)}` : '-'}</td>
<td className="py-2 text-right text-warning">{empAmt != null ? `¥${fmt(empAmt)}` : '-'}</td>
<td className="py-2 text-right font-medium text-primary">{total != null ? `¥${fmt(total)}` : '-'}</td>
<td className="py-2 text-center text-xs text-gray-500">{r.changeType}</td>
<td className="py-2 text-gray-400 text-xs">{new Date(r.processedAt).toLocaleString('zh-CN')}</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
</>
)}
</div>
)
}
/** 参保城市变更历史组件(含修正功能) */
export function CityHistoryTab({ socialInsRecords, housingFundRecords, changeTypeMap, changeTypeColor }: { socialInsRecords: any[]; housingFundRecords: any[]; changeTypeMap: Record<string, string>; changeTypeColor: Record<string, string> }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [correctForm, setCorrectForm] = useState({ city: '', base: '', startMonth: '', endMonth: '', changeType: '', remark: '', reason: '' })
const correctMutation = useMutation({
mutationFn: async (data: any) => {
const cat = editing.cat
const url = cat === '社保'
? `/social/records/social/${editing.id}/correct`
: `/social/records/housing/${editing.id}/correct`
const res = await api.put(url, data) as any
return res.data
},
onSuccess: () => {
toast.success('记录已修正')
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setEditing(null)
},
onError: () => toast.error('修正失败'),
})
const handleCorrect = () => {
correctMutation.mutate({
city: correctForm.city || undefined,
base: correctForm.base ? Number(correctForm.base) : undefined,
startMonth: correctForm.startMonth || undefined,
endMonth: correctForm.endMonth || undefined,
changeType: correctForm.changeType || undefined,
remark: correctForm.remark || undefined,
reason: correctForm.reason || '数据修正',
})
}
const openCorrect = (r: any) => {
setEditing(r)
setCorrectForm({
city: r.city || '',
base: String(r.base || ''),
startMonth: r.startMonth || '',
endMonth: r.endMonth || '',
changeType: r.changeType || '',
remark: r.remark || '',
reason: '',
})
}
const allRecords = [
...(socialInsRecords || []).map((r: any) => ({ ...r, cat: '社保' })),
...(housingFundRecords || []).map((r: any) => ({ ...r, cat: '公积金' })),
].sort((a, b) => b.startMonth.localeCompare(a.startMonth))
if (!allRecords.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
return (
<>
<Card>
<div className="text-xs text-gray-500 mb-3"> + </div>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{allRecords.map((r, idx) => (
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{r.startMonth}</td>
<td className="py-2 text-gray-400">{r.endMonth || '至今'}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${r.cat === '社保' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{r.cat}</span></td>
<td className="py-2"><span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs">{r.city || '-'}</span></td>
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-2 text-center"><button onClick={() => openCorrect(r)} className="text-xs text-primary hover:underline"></button></td>
</tr>
))}
</tbody>
</table>
</Card>
{editing && (
<Modal open={true} onClose={() => setEditing(null)} title={`修正${editing.cat}记录`}>
<div className="space-y-3">
<div className="bg-amber-50 text-amber-700 text-xs px-3 py-2 rounded-md">
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={correctForm.city} onChange={(e) => setCorrectForm({ ...correctForm, city: e.target.value })} placeholder="如 北京" />
</div>
<div>
<Label></Label>
<Input type="number" value={correctForm.base} onChange={(e) => setCorrectForm({ ...correctForm, base: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={correctForm.startMonth} onChange={(e) => setCorrectForm({ ...correctForm, startMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={correctForm.endMonth} onChange={(e) => setCorrectForm({ ...correctForm, endMonth: e.target.value })} placeholder="留空=至今" />
</div>
<div>
<Label></Label>
<Select value={correctForm.changeType} onChange={(e) => setCorrectForm({ ...correctForm, changeType: e.target.value })}>
<option value="ONBOARDING"></option>
<option value="REHIRE"></option>
<option value="ADJUST"></option>
<option value="CITY_CHANGE"></option>
<option value="TERMINATION">/</option>
</Select>
</div>
<div>
<Label></Label>
<Input value={correctForm.remark} onChange={(e) => setCorrectForm({ ...correctForm, remark: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={correctForm.reason} onChange={(e) => setCorrectForm({ ...correctForm, reason: e.target.value })} placeholder="如:城市录入错误" />
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}></Button>
<Button size="sm" onClick={handleCorrect} disabled={correctMutation.isPending}>{correctMutation.isPending ? '保存中...' : '确认修正'}</Button>
</div>
</div>
</Modal>
)}
</>
)
}
@@ -0,0 +1,100 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
// ========== 绩效记录管理 ==========
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/performance`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/performance/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
})
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{records?.length || 0}</h2>
<Button size="sm" onClick={() => setShowForm(!showForm)}></Button>
</div>
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
</Select>
</div>
<div><Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
<div className="md:col-span-2"><Label></Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
<div><Label></Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
<div className="flex items-center gap-2 pt-6">
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<label htmlFor="perfAck" className="text-xs"></label>
</div>
{form.employeeAck && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}></Button></div>
</div>
</Card>
)}
{records?.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : records?.map((r) => (
<Card key={r.id} className="p-4">
<div className="flex justify-between items-start gap-3">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{r.period}</span>
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{resultMap[r.result] || r.result}
</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs"> {r.score} · {r.grade}</span>
</div>
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
{r.improvementPlan && (
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
<span className="font-medium"></span>{r.improvementPlan}
</div>
)}
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
{r.employeeAck ? (
<span className="text-safe flex items-center gap-1"><Check className="w-3 h-3" />{r.ackDate?.toString().slice(0, 10)}</span>
) : (
<span className="text-warning flex items-center gap-1"><AlertTriangle className="w-3 h-3" /></span>
)}
{r.reviewer && <span>{r.reviewer}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</Card>
))}
</div>
)
}
@@ -0,0 +1,323 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { generateEvidenceText } from "./EvidenceChain"
export default function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) {
const [printRecord, setPrintRecord] = useState<any | null>(null)
const [showEvidence, setShowEvidence] = useState(false)
const reasonMap: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
RESIGNATION: '员工主动离职',
}
const legalBasisMap: Record<string, string> = {
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
NONFAULT: '《劳动合同法》第40条', LAYOFF: '《劳动合同法》第41条',
EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条',
}
const { data: evidenceChain, isLoading: evidenceLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
enabled: !!printRecord || showEvidence,
})
const validRecords = (records || []).filter((t: any) => t.status !== 'CANCELLED')
const cancelledRecords = (records || []).filter((t: any) => t.status === 'CANCELLED')
if (!validRecords.length && !cancelledRecords.length) return <Card><div className="text-center py-8 text-gray-400">/</div></Card>
if (printRecord) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<button onClick={() => setPrintRecord(null)} className="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-xs">
<X className="w-4 h-4" />
</button>
<Button variant="secondary" size="sm" onClick={() => window.print()}>
<Printer className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 1. 解聘通知书 */}
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
<div className="text-center">
<h2 className="text-base font-bold"></h2>
</div>
<div className="text-xs text-gray-700 space-y-3">
<p><strong>{profile.name}</strong> /</p>
<p>
<strong>{profile.hireDate?.toString().slice(0, 10)}</strong> {profile.department}
<strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong>
</p>
<p>{legalBasisMap[printRecord.reason] || ''}</p>
<p><strong>¥{fmt(printRecord.compensation)}</strong></p>
{printRecord.remark && <p>{printRecord.remark}</p>}
<p></p>
<div className="text-right mt-6 space-y-1">
<p></p>
<p className="text-gray-400">{printRecord.terminationDate?.toString().slice(0, 10)}</p>
</div>
</div>
</div>
{/* 2. 费用结算明细 */}
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
<div className="flex justify-between"><span></span><span>{profile.name}{profile.department}</span></div>
<div className="flex justify-between"><span></span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>¥{fmt(profile.monthlySalary)}/</span></div>
<div className="flex justify-between"><span></span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span></span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{fmt(printRecord.compensation)}</span></div>
</div>
</div>
{/* 3. 合规检查清单 */}
{printRecord.checklist && Object.keys(printRecord.checklist).length > 0 && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-xs space-y-1">
{Object.entries(printRecord.checklist).map(([key, passed]: [string, any]) => (
<div key={key} className="flex items-center gap-2">
<span className={passed ? 'text-safe' : 'text-danger'}>{passed ? '✓' : '✗'}</span>
<span className={passed ? '' : 'text-gray-500'}>{key}</span>
</div>
))}
</div>
</div>
)}
{/* 4. 风险评估 */}
{printRecord.riskLevel && printRecord.riskLevel !== 'SAFE' && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="text-xs font-medium flex items-center gap-2"><AlertTriangle className="w-4 h-4" /></h3>
<div className="text-xs">
<div className={`px-3 py-2 rounded-md ${printRecord.riskLevel === 'DANGER' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-800'}`}>
{printRecord.riskLevel === 'DANGER' ? '高风险' : '注意'}
</div>
</div>
</div>
)}
{/* 5. 仲裁证据链 */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" /></h3>
{evidenceChain ? (
<>
<div className="text-xs text-gray-500">
{evidenceChain.summary?.total || 0}
{evidenceChain.summary?.signed || 0}
{evidenceChain.summary?.unsigned || 0}
</div>
{(() => {
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
(acc[e.category] = acc[e.category] || []).push(e)
return acc
}, {})
return Object.entries(grouped).map(([category, items]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-medium text-gray-700">{category}</div>
{(items as any[]).map((e: any, i: number) => (
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
<div className="flex items-center gap-2">
<span>{e.title}</span>
{e.acknowledged === true && <span className="text-safe"></span>}
{e.acknowledged === false && <span className="text-danger"></span>}
</div>
<div className="text-gray-400">{e.description}</div>
</div>
))}
</div>
))
})()}
</>
) : (
<div className="text-xs text-gray-400">...</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-3">
{validRecords.map((t) => (
<Card key={t.id} className="p-4">
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-700">{t.terminationDate?.toString().slice(0, 10)}</span>
<span className={`px-2 py-0.5 rounded text-xs ${t.type === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{reasonMap[t.reason] || t.reason}</span>
{t.type !== 'RESIGNATION' && (
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
</span>
)}
{t.status && (
<span className={`px-2 py-0.5 rounded text-xs ${t.status === 'COMPLETED' ? 'bg-green-50 text-safe' : t.status === 'PENDING_APPROVAL' ? 'bg-amber-50 text-amber-700' : t.status === 'APPROVED' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
{t.status === 'DRAFT' ? '草稿' : t.status === 'PENDING_APPROVAL' ? '待审批' : t.status === 'APPROVED' ? '已审批' : t.status === 'COMPLETED' ? '已完成' : t.status === 'REJECTED' ? '已驳回' : t.status === 'EXECUTING' ? '执行中' : t.status}
</span>
)}
</div>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
{t.type === 'RESIGNATION' ? (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">{t.resignationReason || '-'}</span>
</div>
</>
) : (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-700 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
</div>
</>
)}
</div>
{t.remark && <div className="text-xs text-gray-500 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
<div className="flex justify-end">
{t.type !== 'RESIGNATION' && (
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
<Printer className="w-4 h-4 mr-1" />
</Button>
)}
</div>
</div>
</Card>
))}
{cancelledRecords.length > 0 && (
<>
{validRecords.length > 0 && <div className="text-xs text-gray-400 pt-2"></div>}
{cancelledRecords.map((t) => (
<Card key={t.id} className="p-4 opacity-60">
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-500">{t.terminationDate?.toString().slice(0, 10)}</span>
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{reasonMap[t.reason] || t.reason}</span>
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-500 line-through"></span>
</div>
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
{t.type === 'RESIGNATION' ? (
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">{t.resignationReason || '-'}</span>
</div>
) : (
<>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
</div>
<div className="flex justify-between border-b pb-1.5">
<span className="text-gray-400 shrink-0"></span>
<span className="font-medium text-gray-500 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
</div>
</>
)}
</div>
{t.remark && <div className="text-xs text-gray-400 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
</div>
</Card>
))}
</>
)}
{/* 仲裁证据链 */}
<div className="pt-3 border-t">
<button
onClick={() => setShowEvidence(!showEvidence)}
className="flex items-center gap-2 text-xs font-medium text-gray-600 hover:text-gray-800"
>
<Scale className="w-4 h-4" />
{evidenceChain && (
<span className="text-gray-400">
{evidenceChain.summary?.total || 0}{evidenceChain.summary?.signed || 0}{evidenceChain.summary?.unsigned || 0}
</span>
)}
<span className="text-gray-400">{showEvidence ? '▾' : '▸'}</span>
</button>
{showEvidence && (
<div className="mt-3 space-y-2">
{evidenceLoading ? (
<div className="text-xs text-gray-400 text-center py-4">...</div>
) : evidenceChain ? (
<>
{evidenceChain.risks && evidenceChain.risks.length > 0 && (
<Card className="p-3">
<div className="text-xs font-medium mb-2 flex items-center gap-1 text-danger">
<AlertTriangle className="w-3.5 h-3.5" />{evidenceChain.risks.length}
</div>
<div className="space-y-1.5">
{evidenceChain.risks.map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.title}</span>
<span className="text-xs opacity-70 ml-2">{r.category}</span>
<div className="opacity-90 mt-0.5">{r.description}</div>
</div>
))}
</div>
</Card>
)}
{evidenceChain.evidence?.map((e: any, i: number) => (
<Card key={i} className="p-3">
<div className="flex items-start gap-2">
<span className="px-1.5 py-0.5 rounded border text-xs shrink-0 bg-gray-50 text-gray-600 border-gray-200">{e.category}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-xs">{e.title}</span>
<span className="text-xs text-gray-400">{e.date}</span>
{e.acknowledged === true && <span className="text-xs text-safe"></span>}
{e.acknowledged === false && <span className="text-xs text-warning"></span>}
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger"></span>}
</div>
<div className="text-xs text-gray-600 mt-0.5">{e.description}</div>
</div>
</div>
</Card>
))}
<button
onClick={() => {
const text = generateEvidenceText(evidenceChain)
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 = `仲裁证据链_${evidenceChain.employee?.name}_${new Date().toISOString().slice(0, 10)}.txt`
a.click()
URL.revokeObjectURL(url)
}}
className="text-xs text-primary hover:underline"
>
</button>
</>
) : (
<div className="text-xs text-gray-400 text-center py-4"></div>
)}
</div>
)}
</div>
</div>
)
}
+769
View File
@@ -0,0 +1,769 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
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 Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { fmt } from "./shared"
export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newSalary: '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newSalary: parseFloat(form.newSalary),
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
return (
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调薪'}</Button>
</div>
</div>
</Modal>
)
}
export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newDepartment: employee.department || '',
effectiveDate: todayStr,
reason: '',
})
const handleSubmit = () => {
onSubmit({
newDepartment: form.newDepartment,
effectiveDate: new Date(form.effectiveDate).toISOString(),
reason: form.reason || undefined,
})
}
const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department
return (
<Modal open onClose={onClose} title={`调部门 - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.newDepartment} onChange={(e) => setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调部门'}</Button>
</div>
</div>
</Modal>
)
}
export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
terminationDate: new Date().toISOString().slice(0, 10),
resignationReason: '个人原因',
remark: '',
socialInsEndMonth: '',
housingFundEndMonth: '',
})
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
const handleSubmit = () => {
onSubmit({
employeeId: employee.id,
terminationDate: new Date(form.terminationDate).toISOString(),
resignationReason: form.resignationReason,
remark: form.remark || undefined,
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
})
}
return (
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
</div>
<div>
<Label></Label>
<Input
type="date"
value={form.terminationDate}
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
/>
</div>
<div>
<Label></Label>
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
</div>
</div>
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
/
</div>
)}
</div>
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
</div>
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? '提交中...' : '确认离职'}
</Button>
</div>
</div>
</Modal>
)
}
export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
employee: any
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
queryKey: ['contract-types'],
queryFn: async () => {
const res = await api.get('/roster/contract-types') as any
return res.data || []
},
staleTime: Infinity,
})
const defaultEndDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() + 3)
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
hireDate: todayStr,
department: employee.department || '',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '',
startDate: todayStr,
endDate: defaultEndDate,
contractYears: 3,
probationMonths: 0,
probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 计算合同月数
const contractMonths = (() => {
if (form.contractType !== 'FIXED' || !form.startDate) return 0
if (form.endDate) {
const start = new Date(form.startDate)
const end = new Date(form.endDate)
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
}
return form.contractYears * 12
})()
// 试用期上限(劳动合同法第19条)
const probationMax = (() => {
if (contractMonths >= 36) return 6
if (contractMonths >= 12) return 2
if (contractMonths >= 3) return 1
return 0
})()
const probationError = (() => {
if (form.probationMonths <= 0) return ''
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
return ''
})()
const monthlySalaryNum = employee?.monthlySalary || 0
const probationSalaryError = (() => {
if (form.probationMonths <= 0) return ''
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)}`
}
return ''
})()
// 合同结束日期自动计算
const handleContractYearsChange = (years: number) => {
if (!form.startDate || years <= 0) {
setForm({ ...form, contractYears: years, endDate: '' })
return
}
const start = new Date(form.startDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + years)
end.setDate(end.getDate() - 1)
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
}
// 合同结束日期变更 → 自动计算签约年限
const handleEndDateChange = (endDate: string) => {
if (!form.startDate || !endDate) {
setForm({ ...form, endDate, contractYears: 0 })
return
}
const start = new Date(form.startDate)
const end = new Date(endDate)
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
}
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
const handleHireDateChange = (hireDate: string) => {
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
const start = new Date(hireDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + form.contractYears)
end.setDate(end.getDate() - 1)
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
} else {
setForm({ ...form, hireDate, startDate: hireDate })
}
}
const handleSubmit = () => {
const data: any = {
hireDate: new Date(form.hireDate).toISOString(),
department: form.department,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType,
contractYears: form.contractYears,
probationMonths: form.probationMonths,
probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
const canSubmit = form.hireDate
&& (form.contractType === 'UNSIGNED' || form.startDate)
&& !probationError && !probationSalaryError
return (
<Modal open onClose={onClose} title={`重新入职 - ${employee.name}`}>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label> *</Label>
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
</div>
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
</div>
<div className="border-t pt-3">
<Label></Label>
<div className="text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<div className="grid grid-cols-4 gap-3">
<div className="col-span-1">
<Label></Label>
<Select value={form.contractType} onChange={(e) => {
const ct = contractTypes.find(t => t.value === e.target.value)
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
}}>
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
</div>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
<div className="text-xs text-gray-400 mt-0.5"></div>
</div>
<div><Label></Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
</div>
)}
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
</div>
)}
</div>
{form.contractType === 'FIXED' && (
<div className="text-xs text-gray-400"></div>
)}
<div className="grid grid-cols-4 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
{contractMonths > 0 && (
<div className="text-xs text-gray-400 mt-0.5">{probationMax}</div>
)}
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
</div>
<div>
<Label>{form.probationMonths > 0 ? ' *' : ''}</Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
<div className="text-xs text-gray-400 mt-0.5">80%¥{(monthlySalaryNum * 0.8).toFixed(0)}</div>
)}
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
</div>
</div>
</div>
)}
{error && (
<div className="text-xs text-danger">
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '提交中...' : '确认入职'}</Button>
</div>
</div>
</Modal>
)
}
export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const { data: cities = ['北京'] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
const res = await api.get('/social/config/cities') as any
return res.data?.length ? res.data : ['北京']
},
})
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
queryKey: ['contract-types'],
queryFn: async () => {
const res = await api.get('/roster/contract-types') as any
return res.data || []
},
staleTime: Infinity,
})
const defaultEndDate = (() => {
const d = new Date()
d.setFullYear(d.getFullYear() + 3)
d.setDate(d.getDate() - 1)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
name: '', department: '', hireDate: todayStr, monthlySalary: '',
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
city: '北京',
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: todayStr, endDate: defaultEndDate,
contractYears: 3, probationMonths: 0, probationSalary: 0,
socialInsBase: '', socialInsStartMonth: '',
housingFundBase: '', housingFundStartMonth: '',
})
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
const handleHireDateChange = (hireDate: string) => {
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
const start = new Date(hireDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + form.contractYears)
end.setDate(end.getDate() - 1)
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
} else {
setForm({ ...form, hireDate, startDate: hireDate })
}
}
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
const handleIdCardChange = (idCard: string) => {
let gender = form.gender
if (idCard.length >= 17) {
const digit = parseInt(idCard[16])
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
}
setForm({ ...form, idCardNumber: idCard, gender })
}
// 计算合同月数
const contractMonths = (() => {
if (form.contractType !== 'FIXED' || !form.startDate) return 0
if (form.endDate) {
const start = new Date(form.startDate)
const end = new Date(form.endDate)
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
}
return form.contractYears * 12
})()
// 试用期上限(劳动合同法第19条)
const probationMax = (() => {
if (contractMonths >= 36) return 6
if (contractMonths >= 12) return 2
if (contractMonths >= 3) return 1
return 0
})()
const probationError = (() => {
if (form.probationMonths <= 0) return ''
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
return ''
})()
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
const probationSalaryError = (() => {
if (form.probationMonths <= 0) return ''
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)}`
}
return ''
})()
// 合同结束日期自动计算
const handleContractYearsChange = (years: number) => {
if (!form.startDate || years <= 0) {
setForm({ ...form, contractYears: years, endDate: '' })
return
}
const start = new Date(form.startDate)
const end = new Date(start)
end.setFullYear(end.getFullYear() + years)
end.setDate(end.getDate() - 1)
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
}
// 合同结束日期变更 → 自动计算签约年限
const handleEndDateChange = (endDate: string) => {
if (!form.startDate || !endDate) {
setForm({ ...form, endDate, contractYears: 0 })
return
}
const start = new Date(form.startDate)
const end = new Date(endDate)
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
}
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
housingFundStartMonth: form.housingFundStartMonth || undefined,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType, contractYears: form.contractYears,
probationMonths: form.probationMonths, probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
&& form.idCardNumber.length >= 18
&& (form.contractType === 'UNSIGNED' || form.startDate)
&& !probationError && !probationSalaryError
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
useUnsavedChanges(isDirty)
return (
<Modal open onClose={onClose} title="添加员工" size="xl">
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
{error.response?.data?.error?.message || '操作失败'}
</div>
)}
{/* 基本信息 */}
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
<div><Label> *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
<div><Label> *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
{form.gender === '女' && (
<div><Label></Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value=""></option><option value="CADRE">/</option><option value="WORKER">/</option></Select></div>
)}
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label> *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
<div><Label> *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
</div>
{/* 社保公积金 */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center gap-2 mb-3">
<Briefcase className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-xs text-gray-500"></span>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
</div>
<div>
<Label></Label>
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
</div>
</div>
</div>
{/* 合同信息 */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center gap-2 mb-3">
<FileSignature className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"></span>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="col-span-1">
<Label></Label>
<Select value={form.contractType} onChange={(e) => {
const ct = contractTypes.find(t => t.value === e.target.value)
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
}}>
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
</div>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div><Label></Label><div className="text-sm text-gray-600 py-2">{form.startDate || '随入职日期'}</div></div>
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
</div>
)}
{form.contractType === 'FIXED' && (
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
</div>
)}
</div>
{form.contractType === 'FIXED' && (
<div className="text-xs text-gray-500"></div>
)}
<div className="grid grid-cols-4 gap-4">
<div>
<Label></Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
{contractMonths > 0 && (
<div className="text-xs text-gray-500 mt-1">{probationMax}</div>
)}
{probationError && <div className="text-xs text-danger mt-1">{probationError}</div>}
</div>
<div>
<Label>{form.probationMonths > 0 ? ' *' : ''}</Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
<div className="text-xs text-gray-500 mt-1">80%¥{(monthlySalaryNum * 0.8).toFixed(0)}</div>
)}
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
</div>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-3 border-t border-gray-200">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
)
}
+66
View File
@@ -0,0 +1,66 @@
/** Roster 共享类型与常量 */
/** 金额格式化:保留两位小数 + 千分位 */
export const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 解聘原因映射 */
export const terminateReasonMap: Record<string, string> = {
NEGOTIATED: '协商解除',
FAULT: '员工过错',
NONFAULT: '非过错解除',
LAYOFF: '经济性裁员',
EXPIRED: '合同到期不续签',
}
/** 详情页 Tab 类型 */
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history'
/** Tab 分组 */
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
/** Tab 分组配置 */
export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: string; icon: any }[] }[] = [
{
group: '人事信息',
tabs: [
{ key: 'basic', label: '基本信息', icon: null },
{ key: 'contract', label: '劳动合同', icon: null },
],
},
{
group: '薪酬',
tabs: [
{ key: 'payslip', label: '薪酬社保', icon: null },
],
},
{
group: '考勤绩效',
tabs: [
{ key: 'attendance', label: '考勤培训', icon: null },
{ key: 'performance', label: '绩效考核', icon: null },
],
},
{
group: '风险合规',
tabs: [
{ key: 'disciplinary', label: '违纪记录', icon: null },
{ key: 'termination', label: '离职/解聘', icon: null },
],
},
{
group: '变更历史',
tabs: [
{ key: 'history', label: '变更历史', icon: null },
],
},
]
/** Tab 计数字段映射 */
export const TAB_COUNT_KEYS: Record<string, string> = {
contract: 'contracts',
payslip: 'payslips',
attendance: 'attendanceRecords',
disciplinary: 'disciplinaryRecords',
performance: 'performanceRecords',
termination: 'terminations',
}