feat: 员工特殊状态台账(三期/工伤/医疗期)完整实现

This commit is contained in:
selfrelease
2026-07-30 15:04:34 +08:00
parent 5a5ce7186b
commit 826c8fda65
11 changed files with 1275 additions and 6 deletions
+610
View File
@@ -0,0 +1,610 @@
/**
* 员工特殊状态台账页面
* 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒
*/
import { useEffect, useState } from 'react'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
import api from '../lib/api'
import { Input, Select, Label } from '../components/ui/Input'
import Button from '../components/ui/Button'
interface SpecialStatus {
id: string
type: string
status: string
startDate: string | null
endDate: string | null
actualEndDate: string | null
expectedDueDate: string | null
maternityLeaveStart: string | null
maternityLeaveEnd: string | null
nursingEndDate: string | null
injuryDate: string | null
injuryDescription: string | null
certificationDate: string | null
certificationNo: string | null
disabilityLevel: number | null
assessmentDate: string | null
medicalMonths: number | null
medicalPeriodEnd: string | null
description: string | null
attachments: any[] | null
timeline: any[] | null
reminderDate: string | null
alertLevel: 'red' | 'yellow' | 'green'
employee: {
id: string; name: string; department: string; phone: string; gender: string; status: string
}
}
interface Stats {
total: number
active: number
pending: number
redAlert: number
yellowAlert: number
byType: { type: string; label: string; count: number }[]
}
const TYPE_LABELS: Record<string, string> = {
PREGNANCY: '三期',
WORK_INJURY: '工伤',
MEDICAL_PERIOD: '医疗期',
OTHER: '其他',
}
const TYPE_ICONS: Record<string, typeof Baby> = {
PREGNANCY: Baby,
WORK_INJURY: Activity,
MEDICAL_PERIOD: HeartPulse,
OTHER: AlertTriangle,
}
const TYPE_COLORS: Record<string, string> = {
PREGNANCY: 'bg-pink-50 text-pink-700 border-pink-200',
WORK_INJURY: 'bg-red-50 text-red-700 border-red-200',
MEDICAL_PERIOD: 'bg-orange-50 text-orange-700 border-orange-200',
OTHER: 'bg-gray-50 text-gray-700 border-gray-200',
}
const STATUS_LABELS: Record<string, string> = {
ACTIVE: '进行中',
PENDING: '待处理',
RESOLVED: '已结束',
}
const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-emerald-50 text-emerald-700',
PENDING: 'bg-amber-50 text-amber-700',
RESOLVED: 'bg-gray-100 text-gray-500',
}
const ALERT_STYLES: Record<string, { border: string; badge: string; text: string }> = {
red: { border: 'border-l-4 border-l-red-500', badge: 'bg-red-100 text-red-700', text: 'text-red-600' },
yellow: { border: 'border-l-4 border-l-amber-500', badge: 'bg-amber-100 text-amber-700', text: 'text-amber-600' },
green: { border: 'border-l-4 border-l-emerald-500', badge: 'bg-emerald-100 text-emerald-700', text: 'text-emerald-600' },
}
function formatDate(d: string | null): string {
if (!d) return '-'
return new Date(d).toLocaleDateString('zh-CN')
}
function daysUntil(d: string | null): number | null {
if (!d) return null
const diff = new Date(d).getTime() - Date.now()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
export default function SpecialStatus() {
const [list, setList] = useState<SpecialStatus[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<Stats | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<SpecialStatus | null>(null)
const [deleteTarget, setDeleteTarget] = useState<SpecialStatus | null>(null)
const [employees, setEmployees] = useState<any[]>([])
const [form, setForm] = useState<any>(getDefaultForm())
function getDefaultForm() {
return {
employeeId: '',
type: 'PREGNANCY',
status: 'ACTIVE',
startDate: '',
endDate: '',
actualEndDate: '',
expectedDueDate: '',
injuryDate: '',
injuryDescription: '',
certificationDate: '',
certificationNo: '',
disabilityLevel: '',
assessmentDate: '',
medicalMonths: '',
description: '',
reminderDate: '',
}
}
const fetchList = async () => {
setLoading(true)
try {
const params: any = { page, pageSize }
if (search) params.search = search
if (typeFilter) params.type = typeFilter
if (statusFilter) params.status = statusFilter
const res = await api.get('/special-statuses', { params }) as any
setList(res.data.list)
setTotal(res.data.total)
} finally {
setLoading(false)
}
}
const fetchStats = async () => {
try {
const res = await api.get('/special-statuses/stats/overview') as any
setStats(res.data)
} catch {
// 忽略
}
}
const fetchEmployees = async () => {
try {
const res = await api.get('/employees', { params: { pageSize: 999 } }) as any
setEmployees(res.data.list || [])
} catch {
// 忽略
}
}
useEffect(() => { fetchList(); fetchStats() }, [page, typeFilter, statusFilter])
useEffect(() => { setPage(1) }, [search, typeFilter, statusFilter])
const handleOpenCreate = () => {
setEditing(null)
setForm(getDefaultForm())
setEditOpen(true)
fetchEmployees()
}
const handleOpenEdit = (item: SpecialStatus) => {
setEditing(item)
setForm({
employeeId: item.employee.id,
type: item.type,
status: item.status,
startDate: item.startDate ? item.startDate.split('T')[0] : '',
endDate: item.endDate ? item.endDate.split('T')[0] : '',
actualEndDate: item.actualEndDate ? item.actualEndDate.split('T')[0] : '',
expectedDueDate: item.expectedDueDate ? item.expectedDueDate.split('T')[0] : '',
injuryDate: item.injuryDate ? item.injuryDate.split('T')[0] : '',
injuryDescription: item.injuryDescription || '',
certificationDate: item.certificationDate ? item.certificationDate.split('T')[0] : '',
certificationNo: item.certificationNo || '',
disabilityLevel: item.disabilityLevel || '',
assessmentDate: item.assessmentDate ? item.assessmentDate.split('T')[0] : '',
medicalMonths: item.medicalMonths || '',
description: item.description || '',
reminderDate: item.reminderDate ? item.reminderDate.split('T')[0] : '',
})
setEditOpen(true)
fetchEmployees()
}
const handleSave = async () => {
if (!form.employeeId) { alert('请选择员工'); return }
try {
const data: any = { ...form }
// 空字符串转 null
Object.keys(data).forEach((k) => {
if (data[k] === '') data[k] = null
})
if (data.disabilityLevel) data.disabilityLevel = parseInt(data.disabilityLevel)
if (data.medicalMonths) data.medicalMonths = parseInt(data.medicalMonths)
if (editing) {
await api.put(`/special-statuses/${editing.id}`, data)
} else {
await api.post('/special-statuses', data)
}
setEditOpen(false)
fetchList()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.error?.message || '保存失败')
}
}
const handleDelete = async () => {
if (!deleteTarget) return
try {
await api.delete(`/special-statuses/${deleteTarget.id}`)
setDeleteTarget(null)
fetchList()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.error?.message || '删除失败')
}
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6">
{/* 标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">//</p>
</div>
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500"></div>
<div className="text-xl font-bold text-gray-900">{stats.total}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500"></div>
<div className="text-xl font-bold text-emerald-600">{stats.active}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500"></div>
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
</div>
<div className="bg-white border border-red-200 rounded-lg p-3">
<div className="text-xs text-gray-500"></div>
<div className="text-xl font-bold text-red-600">{stats.redAlert}</div>
</div>
<div className="bg-white border border-amber-200 rounded-lg p-3">
<div className="text-xs text-gray-500"></div>
<div className="text-xl font-bold text-amber-600">{stats.yellowAlert}</div>
</div>
<div className="bg-white border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-xs text-gray-700">
{stats.byType.map((t) => (
<span key={t.type} className="mr-2">{t.label}: <b>{t.count}</b></span>
))}
</div>
</div>
</div>
)}
{/* 筛选栏 */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="搜索员工姓名、手机号..."
className="pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchList()}
/>
</div>
<Select className="w-32" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
<option value=""></option>
<option value="PREGNANCY"></option>
<option value="WORK_INJURY"></option>
<option value="MEDICAL_PERIOD"></option>
<option value="OTHER"></option>
</Select>
<Select className="w-32" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value=""></option>
<option value="ACTIVE"></option>
<option value="PENDING"></option>
<option value="RESOLVED"></option>
</Select>
<Button variant="secondary" onClick={fetchList}></Button>
<Button onClick={handleOpenCreate}><Plus className="w-4 h-4 mr-1" /></Button>
</div>
{/* 列表 */}
{loading ? (
<div className="text-center py-12 text-gray-400">...</div>
) : list.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{list.map((item) => {
const Icon = TYPE_ICONS[item.type] || AlertTriangle
const alert = ALERT_STYLES[item.alertLevel] || ALERT_STYLES.green
const reminderDays = daysUntil(item.reminderDate)
return (
<div key={item.id} className={`bg-white border ${alert.border} border-gray-200 rounded-lg p-4`}>
{/* 头部 */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${TYPE_COLORS[item.type] || TYPE_COLORS.OTHER}`}>
<Icon className="w-3 h-3 inline mr-1" />
{TYPE_LABELS[item.type] || item.type}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[item.status] || STATUS_COLORS.RESOLVED}`}>
{STATUS_LABELS[item.status] || item.status}
</span>
</div>
<div className="flex gap-1">
<button onClick={() => handleOpenEdit(item)} className="p-1 rounded text-gray-400 hover:text-primary hover:bg-gray-100" title="编辑">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeleteTarget(item)} className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 员工信息 */}
<div className="mb-3">
<span className="font-medium text-gray-900">{item.employee.name}</span>
<span className="text-sm text-gray-500 ml-2">{item.employee.department}</span>
{item.employee.status === 'RESIGNED' && (
<span className="ml-2 text-xs text-gray-400"></span>
)}
</div>
{/* 关键日期 */}
<div className="space-y-1.5 text-sm">
{item.type === 'PREGNANCY' && (
<>
{item.expectedDueDate && <Row label="预产期" value={formatDate(item.expectedDueDate)} />}
{item.maternityLeaveStart && <Row label="产假开始" value={formatDate(item.maternityLeaveStart)} />}
{item.maternityLeaveEnd && <Row label="产假结束" value={formatDate(item.maternityLeaveEnd)} />}
{item.nursingEndDate && <Row label="哺乳期截止" value={formatDate(item.nursingEndDate)} />}
</>
)}
{item.type === 'WORK_INJURY' && (
<>
{item.injuryDate && <Row label="受伤日期" value={formatDate(item.injuryDate)} />}
{item.injuryDescription && <Row label="伤情描述" value={item.injuryDescription} />}
{item.certificationDate ? (
<Row label="认定日期" value={formatDate(item.certificationDate)} />
) : (
<Row label="认定日期" value="待认定" valueClass="text-amber-600" />
)}
{item.assessmentDate && <Row label="鉴定日期" value={formatDate(item.assessmentDate)} />}
{item.disabilityLevel && <Row label="伤残等级" value={`${item.disabilityLevel}`} />}
</>
)}
{item.type === 'MEDICAL_PERIOD' && (
<>
{item.startDate && <Row label="开始日期" value={formatDate(item.startDate)} />}
{item.medicalMonths && <Row label="医疗期" value={`${item.medicalMonths}个月`} />}
{item.medicalPeriodEnd && <Row label="截止日期" value={formatDate(item.medicalPeriodEnd)} />}
</>
)}
{item.type === 'OTHER' && (
<>
{item.startDate && <Row label="开始日期" value={formatDate(item.startDate)} />}
{item.endDate && <Row label="预计结束" value={formatDate(item.endDate)} />}
</>
)}
</div>
{/* 提醒 */}
{item.status === 'ACTIVE' && reminderDays !== null && (
<div className={`mt-3 pt-3 border-t border-gray-100 flex items-center gap-1.5 text-xs ${alert.text}`}>
<Clock className="w-3.5 h-3.5" />
{reminderDays < 0 ? `已过期 ${Math.abs(reminderDays)}` : `距提醒日还有 ${reminderDays}`}
</div>
)}
{item.description && (
<div className="mt-2 text-xs text-gray-400 line-clamp-2">{item.description}</div>
)}
</div>
)
})}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<span className="text-sm text-gray-500">{page} / {totalPages}</span>
<Button variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
)}
</>
)}
{/* 新增/编辑弹窗 */}
{editOpen && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setEditOpen(false)}>
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">{editing ? '编辑特殊状态' : '新增特殊状态'}</h2>
<button onClick={() => setEditOpen(false)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
{/* 员工选择 */}
<div>
<Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })} disabled={!!editing}>
<option value=""></option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
{/* 类型 + 状态 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="PREGNANCY"></option>
<option value="WORK_INJURY"></option>
<option value="MEDICAL_PERIOD"></option>
<option value="OTHER"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
<option value="ACTIVE"></option>
<option value="PENDING"></option>
<option value="RESOLVED"></option>
</Select>
</div>
</div>
{/* 三期专用字段 */}
{form.type === 'PREGNANCY' && (
<div className="border-t border-gray-100 pt-3 space-y-3">
<h3 className="text-xs text-primary font-medium"></h3>
<div>
<Label></Label>
<Input type="date" value={form.expectedDueDate} onChange={(e) => setForm({ ...form, expectedDueDate: e.target.value })} />
<p className="text-xs text-gray-400 mt-1"></p>
</div>
</div>
)}
{/* 工伤专用字段 */}
{form.type === 'WORK_INJURY' && (
<div className="border-t border-gray-100 pt-3 space-y-3">
<h3 className="text-xs text-primary font-medium"></h3>
<div>
<Label></Label>
<Input type="date" value={form.injuryDate} onChange={(e) => setForm({ ...form, injuryDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.injuryDescription} onChange={(e) => setForm({ ...form, injuryDescription: e.target.value })} placeholder="简要描述伤情" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.certificationDate} onChange={(e) => setForm({ ...form, certificationDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.certificationNo} onChange={(e) => setForm({ ...form, certificationNo: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>1-10</Label>
<Input type="number" min={1} max={10} value={form.disabilityLevel} onChange={(e) => setForm({ ...form, disabilityLevel: e.target.value })} placeholder="未鉴定则留空" />
</div>
<div>
<Label></Label>
<Input type="date" value={form.assessmentDate} onChange={(e) => setForm({ ...form, assessmentDate: e.target.value })} />
</div>
</div>
</div>
)}
{/* 医疗期专用字段 */}
{form.type === 'MEDICAL_PERIOD' && (
<div className="border-t border-gray-100 pt-3 space-y-3">
<h3 className="text-xs text-primary font-medium"></h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.medicalMonths} onChange={(e) => setForm({ ...form, medicalMonths: e.target.value })} placeholder="如3/6/9/12/24" />
</div>
</div>
<p className="text-xs text-gray-400"></p>
</div>
)}
{/* 其他类型 */}
{form.type === 'OTHER' && (
<div className="border-t border-gray-100 pt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
</div>
</div>
</div>
)}
{/* 通用字段 */}
<div className="border-t border-gray-100 pt-3 space-y-3">
{form.status === 'RESOLVED' && (
<div>
<Label></Label>
<Input type="date" value={form.actualEndDate} onChange={(e) => setForm({ ...form, actualEndDate: e.target.value })} />
</div>
)}
<div>
<Label></Label>
<Input type="date" value={form.reminderDate} onChange={(e) => setForm({ ...form, reminderDate: e.target.value })} />
<p className="text-xs text-gray-400 mt-1">730</p>
</div>
<div>
<Label></Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="补充说明" />
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 pt-3">
<Button className="flex-1" onClick={handleSave}></Button>
<Button variant="secondary" onClick={() => setEditOpen(false)}></Button>
</div>
</div>
</div>
</div>
)}
{/* 删除确认 */}
{deleteTarget && (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={() => setDeleteTarget(null)}>
<div className="bg-white border border-gray-200 rounded-lg p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-sm text-gray-500"></p>
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
<b>{deleteTarget.employee.name}</b> <b>{TYPE_LABELS[deleteTarget.type]}</b>
</p>
<div className="flex gap-2">
<Button className="flex-1 bg-red-500 hover:bg-red-600" onClick={handleDelete}></Button>
<Button variant="secondary" onClick={() => setDeleteTarget(null)}></Button>
</div>
</div>
</div>
)}
</div>
)
}
/** 信息行组件 */
function Row({ label, value, valueClass }: { label: string; value: string; valueClass?: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-500 text-xs">{label}</span>
<span className={`text-gray-900 text-xs font-medium ${valueClass || ''}`}>{value}</span>
</div>
)
}