Files
TurboHR/frontend/src/pages/SpecialStatus.tsx
T

611 lines
26 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 { 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>
)
}