Files
TurboHR/frontend/src/pages/Roster.tsx
T
2026-07-23 12:34:43 +08:00

1147 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Signal from '../components/ui/Signal'
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
export default function Roster() {
const queryClient = useQueryClient()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const { data: employees, isLoading } = useQuery<any[]>({
queryKey: ['roster'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data
},
})
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/employees', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowAddModal(false)
},
})
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search)
) || []
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold"></h1>
<div className="flex gap-2">
<Input
placeholder="搜索姓名/部门"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-48"
/>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : filtered.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
<th className="py-2 px-3 text-center"></th>
</tr>
</thead>
<tbody>
{filtered.map((e: any) => (
<tr
key={e.id}
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
onClick={() => setSelectedId(e.id)}
>
<td className="py-2 px-3 font-medium">{e.name}</td>
<td className="py-2 px-3 text-gray-500">{e.department}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '在职' : '离职'}
</span>
</td>
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
<td className="py-2 px-3 text-right">¥{e.monthlySalary.toLocaleString()}</td>
<td className="py-2 px-3">
<Signal level={e.latestContract?.riskLevel || 'safe'} label={e.latestContract ? (e.latestContract.contractType === 'FIXED' ? '固定期限' : e.latestContract.contractType === 'UNFIXED' ? '无固定期限' : '未签') : '无合同'} />
</td>
<td className="py-2 px-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
) : <span className="text-gray-300">0</span>}
</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{showAddModal && (
<AddEmployeeModal
onClose={() => setShowAddModal(false)}
onSubmit={(data) => addMutation.mutate(data)}
loading={addMutation.isPending}
error={addMutation.error as any}
/>
)}
</div>
)
}
function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
const [tab, setTab] = useState<DetailTab>('basic')
const [showEvidence, setShowEvidence] = useState(false)
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 tabs: { key: DetailTab; label: string; icon: any }[] = [
{ key: 'basic', label: '基本信息', icon: Users },
{ key: 'contract', label: '劳动合同', icon: FileText },
{ key: 'payslip', label: '工资条', icon: FileText },
{ key: 'overtime', label: '加班记录', icon: Calendar },
{ key: 'disciplinary', label: '违纪记录', icon: AlertTriangle },
{ key: 'attendance', label: '考勤记录', icon: Calendar },
{ key: 'training', label: '培训签收', icon: GraduationCap },
{ key: 'performance', label: '绩效考核', icon: TrendingUp },
{ key: 'termination', label: '解聘记录', icon: FileText },
{ key: 'attachment', label: '附件管理', icon: Paperclip },
{ key: 'evidence', label: '仲裁证据链', icon: Scale },
]
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="space-y-4">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
<h1 className="text-lg font-semibold">{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">
{tabs.map((t) => {
const Icon = t.icon
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 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'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
)
})}
</div>
{tab === 'basic' && <BasicInfo profile={profile} />}
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} />}
{tab === 'payslip' && <PayslipInfo payslips={profile.payslips} />}
{tab === 'overtime' && <OvertimeInfo records={profile.overtimeRecords} />}
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
{tab === 'attendance' && <AttendanceInfo employeeId={employeeId} records={profile.attendanceRecords} />}
{tab === 'training' && <TrainingInfo employeeId={employeeId} records={profile.trainingRecords} />}
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
{tab === 'attachment' && <AttachmentInfo employeeId={employeeId} attachments={profile.attachments} />}
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
</div>
)
}
function BasicInfo({ profile }: { profile: any }) {
const fields = [
{ label: '姓名', value: profile.name },
{ label: '部门', value: profile.department },
{ label: '性别', value: profile.gender || '未填写' },
{ label: '手机号', value: profile.phone || '未填写' },
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
{ label: '月工资', value: `¥${profile.monthlySalary.toLocaleString()}` },
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
{ label: '住址', value: profile.address || '未填写' },
{ label: '开户行', value: profile.bankName || '未填写' },
{ label: '银行账号', value: profile.bankAccount || '未填写' },
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
]
const special = [
{ label: '孕期', value: profile.isPregnant },
{ label: '医疗期', value: profile.isInMedicalPeriod },
{ label: '工伤', value: profile.isWorkInjured },
]
return (
<Card>
<div className="grid md:grid-cols-2 gap-4">
{fields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-2">
<span className="text-gray-500">{f.label}</span>
<span className="font-medium">{f.value}</span>
</div>
))}
</div>
<div className="mt-4 flex gap-4">
{special.map((s) => (
<span key={s.label} className={`px-3 py-1 rounded text-sm ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
{s.label}{s.value ? '是' : '否'}
</span>
))}
</div>
</Card>
)
}
function ContractInfo({ employeeId, contracts }: { employeeId: string; contracts: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0 })
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="font-medium">{contracts?.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>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
<option value="UNSIGNED"></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' && (
<>
<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">
<Button onClick={() => addContractMutation.mutate(form)} disabled={addContractMutation.isPending || !form.startDate}>
{addContractMutation.isPending ? '保存中...' : '保存'}
</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-2 gap-4 flex-1">
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{typeMap[c.contractType] || c.contractType}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.startDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.contractYears}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.probationMonths}¥{c.probationSalary}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.renewalCount}</span></div>
</div>
</div>
</Card>
))}
</div>
)
}
function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
name: '', department: '', hireDate: '', monthlySalary: '',
gender: '男' as '男' | '女', phone: '',
isPregnant: false, isInMedicalPeriod: false, isWorkInjured: false,
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '', startDate: '', endDate: '',
contractYears: 3, probationMonths: 0, probationSalary: 0,
})
const handleSubmit = () => {
const data: any = {
name: form.name, department: form.department,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary, gender: form.gender,
phone: form.phone || undefined,
isPregnant: form.isPregnant, isInMedicalPeriod: form.isInMedicalPeriod, isWorkInjured: form.isWorkInjured,
}
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)
}
return (
<Modal open onClose={onClose} title="添加员工">
<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-2 gap-3">
<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>
<div className="grid grid-cols-2 gap-3">
<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: e.target.value })} placeholder="元" /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label></Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男"></option><option value="女"></option></Select></div>
<div><Label></Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-sm"><input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />/</label>
<label className="flex items-center gap-1.5 text-sm"><input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} /></label>
<label className="flex items-center gap-1.5 text-sm"><input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} /></label>
</div>
<div className="border-t pt-3">
<Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
<option value="FIXED"></option><option value="UNFIXED"></option><option value="UNSIGNED"></option>
</Select>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<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>
</div>
{form.contractType === 'FIXED' && (
<div className="grid grid-cols-3 gap-3">
<div><Label></Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
<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>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.department || !form.hireDate || !form.monthlySalary}>{loading ? '保存中...' : '保存'}</Button>
</div>
</div>
</Modal>
)
}
function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | '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 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: '银行卡', CONTRACT_SCAN: '合同扫描件', EDUCATION: '学历证书', OTHER: '其他' }
return (
<div className="space-y-3">
<h2 className="font-medium">{attachments?.length || 0}</h2>
<Card>
<div className="flex gap-2 mb-3">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-sm">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="CONTRACT_SCAN"></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}>
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</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 text-sm">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<div className="text-xs text-gray-400">{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}</div>
</div>
</div>
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-400 hover:text-danger shrink-0 ml-2"><Trash2 className="w-4 h-4" /></button>
</div>
))}
</div>
) : <div className="text-gray-400 text-sm text-center py-4"></div>}
</Card>
</div>
)
}
function PayslipInfo({ payslips }: { payslips: any[] }) {
if (!payslips?.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
return (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b 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">
<td className="py-2">{p.month}</td>
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-center">
{p.confirmedAt ? <span className="text-safe text-xs"></span> : <span className="text-warning text-xs"></span>}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)
}
function OvertimeInfo({ records }: { records: any[] }) {
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
return (
<Card>
<table className="w-full text-sm">
<thead>
<tr className="border-b 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>
{records.map((o) => (
<tr key={o.id} className="border-b last:border-0">
<td className="py-2">{o.month}</td>
<td className="py-2 text-right">{o.weekdayHours}</td>
<td className="py-2 text-right">{o.weekendHours}</td>
<td className="py-2 text-right">{o.holidayHours}</td>
<td className="py-2 text-right font-medium">¥{o.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
</tr>
))}
</tbody>
</table>
</Card>
)
}
function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) {
const [printRecord, setPrintRecord] = useState<any | null>(null)
const reasonMap: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
}
const legalBasisMap: Record<string, string> = {
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
NONFAULT: '《劳动合同法》第40条', LAYOFF: '《劳动合同法》第41条',
EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条',
}
const { data: evidenceChain } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
enabled: !!printRecord,
})
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400"></div></Card>
if (printRecord) {
return (
<div className="space-y-4">
<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-sm">
<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-4 print:shadow-none">
<div className="text-center">
<h2 className="text-lg font-bold"></h2>
</div>
<div className="text-sm 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>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</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="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-sm 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>¥{profile.monthlySalary.toLocaleString()}/</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>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</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="font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-sm 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="font-medium flex items-center gap-2"><AlertTriangle className="w-4 h-4" /></h3>
<div className="text-sm">
<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="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-sm 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-sm text-gray-400">...</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-3">
{records.map((t) => (
<Card key={t.id}>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{t.terminationDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{reasonMap[t.reason] || t.reason}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">¥{t.compensation.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{t.riskLevel === 'SAFE' ? '安全' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}</span></div>
{t.remark && <div className="md:col-span-2"><span className="text-gray-500"></span><span>{t.remark}</span></div>}
<div className="md:col-span-2 flex justify-end">
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
<Printer className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</Card>
))}
</div>
)
}
// ========== 违纪记录管理 ==========
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="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-sm"></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">
<Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.violationDate || !form.description}>
{createMutation.isPending ? '保存中...' : '保存'}
</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}>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{r.violationDate?.toString().slice(0, 10)}</span>
<span className="px-2 py-0.5 rounded bg-red-50 text-danger text-xs">{typeMap[r.violationType] || r.violationType}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{severityMap[r.severity] || r.severity}</span>
</div>
<div className="text-sm text-gray-600">{r.description}</div>
<div className="text-sm"><span className="text-gray-500"></span>{actionMap[r.action] || r.action}{r.actionDetail ? `${r.actionDetail}` : ''}</div>
<div className="flex gap-3 text-xs text-gray-400">
{r.employeeAck ? <span className="text-safe"> {r.ackDate?.toString().slice(0, 10)}</span> : <span className="text-warning"> </span>}
{r.witness && <span>{r.witness}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-400 hover:text-danger"></button>
</div>
</Card>
))}
</div>
)
}
// ========== 考勤记录管理 ==========
function AttendanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, 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 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' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="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.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"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button></div>
</div>
</Card>
)}
{records?.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-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>
{records?.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>
)}
</div>
)
}
// ========== 培训签收记录管理 ==========
function TrainingInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
const createMutation = useMutation({
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteMutation = 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 ackColor: Record<string, string> = { PENDING: 'bg-amber-50 text-warning', SIGNED: 'bg-green-50 text-safe', REFUSED: 'bg-red-50 text-danger' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="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.trainingDate} onChange={(e) => setForm({ ...form, trainingDate: e.target.value })} /></div>
<div><Label>/</Label><Input value={form.topic} onChange={(e) => setForm({ ...form, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
<div className="md:col-span-2"><Label></Label><Input value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} /></div>
<div><Label></Label><Input value={form.trainer} onChange={(e) => setForm({ ...form, trainer: e.target.value })} /></div>
<div><Label></Label><Input type="number" step="0.5" value={form.duration} onChange={(e) => setForm({ ...form, duration: Number(e.target.value) })} /></div>
<div><Label></Label>
<Select value={form.ackStatus} onChange={(e) => setForm({ ...form, ackStatus: e.target.value })}>
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
</div>
{form.ackStatus === 'SIGNED' && <div><Label></Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: 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"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.trainingDate || !form.topic}>{createMutation.isPending ? '保存中...' : '保存'}</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}>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{r.trainingDate?.toString().slice(0, 10)}</span>
<span className="font-medium">{r.topic}</span>
<span className={`px-2 py-0.5 rounded text-xs ${ackColor[r.ackStatus] || 'bg-gray-100'}`}>{ackMap[r.ackStatus] || r.ackStatus}</span>
</div>
{r.content && <div className="text-sm text-gray-600">{r.content}</div>}
<div className="flex gap-3 text-xs text-gray-400">
<span>{r.duration}h</span>
{r.trainer && <span>{r.trainer}</span>}
{r.ackDate && <span>{r.ackDate.toString().slice(0, 10)}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-400 hover:text-danger"></button>
</div>
</Card>
))}
</div>
)
}
// ========== 绩效记录管理 ==========
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: '不胜任' }
const resultColor: Record<string, string> = { EXCELLENT: 'bg-green-50 text-safe', QUALIFIED: 'bg-blue-50 text-blue-600', NEED_IMPROVE: 'bg-amber-50 text-warning', UNQUALIFIED: 'bg-red-50 text-danger' }
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="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-sm"></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"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</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}>
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">{r.period}</span>
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{r.score}{r.grade}</span>
<span className={`px-2 py-0.5 rounded text-xs ${resultColor[r.result] || 'bg-gray-100'}`}>{resultMap[r.result] || r.result}</span>
</div>
{r.summary && <div className="text-sm text-gray-600">{r.summary}</div>}
{r.improvementPlan && <div className="text-sm text-warning">{r.improvementPlan}</div>}
<div className="flex gap-3 text-xs text-gray-400">
{r.employeeAck ? <span className="text-safe"> {r.ackDate?.toString().slice(0, 10)}</span> : <span className="text-warning"> </span>}
{r.reviewer && <span>{r.reviewer}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-400 hover:text-danger"></button>
</div>
</Card>
))}
</div>
)
}
// ========== 仲裁证据链 ==========
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)
}
return (
<div className="space-y-4">
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="font-medium flex items-center gap-2"><Scale className="w-5 h-5" /></h2>
<div className="text-sm 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-sm text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold">{data.summary.total}</div>
</div>
<div className="text-sm text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
</div>
<div className="text-sm text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
</div>
<Button onClick={handleExport}></Button>
</div>
</div>
</Card>
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i}>
<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-sm">{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>}
</div>
<div className="text-sm text-gray-600 mt-1">{e.description}</div>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
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')
}