Files
TurboHR/frontend/src/pages/portal/EmployeeHome.tsx
T
freedakgmail 5db53ed591 fix: 员工端身份证号显示兼容旧登录——从 home/overview 获取 fallback
- EmployeeHome.tsx: 使用 overview.employeeIdCardNumber 作为 fallback
- PortalLayout.tsx: 获取 overview 数据作为 idCardNumber fallback
- 两者都会在获取到后同步更新 localStorage,避免重复请求
2026-08-18 07:57:54 +08:00

251 lines
11 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.
/**
* 员工 Hub 首页 — 员工端统一入口
* 展示个人概览、待办事项、快捷入口、公司公告
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight, UserX, PenTool, UserCircle, ClipboardList,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 快捷入口配置 */
const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/esign', label: '电子签署', icon: PenTool, color: 'bg-violet-50 text-violet-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
{ path: '/portal/records', label: '我的记录', icon: ClipboardList, color: 'bg-slate-50 text-slate-600' },
{ path: '/portal/profile', label: '个人资料', icon: UserCircle, color: 'bg-indigo-50 text-indigo-600' },
]
export default function EmployeeHome() {
const employee = (() => {
try { return JSON.parse(localStorage.getItem('portalEmployee') || '{}') } catch { return {} }
})()
/** 获取员工首页概览数据 */
const { data: overview, isLoading } = useQuery<any>({
queryKey: ['portal-home-overview'],
queryFn: async () => {
return await portalApi.homeOverview()
},
})
if (isLoading) {
return <div className="space-y-4">
<div className="h-28 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-32 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
}
const latestPayslip = overview?.latestPayslip
const contractInfo = overview?.contract
const pendingTasks = overview?.pendingTasks || []
const announcements = overview?.announcements || []
const attendanceSummary = overview?.attendance
// 如果 localStorage 中没有 idCardNumber,但 overview 返回了,则更新 localStorage
const idCardNumber = employee.idCardNumber || overview?.employeeIdCardNumber
if (idCardNumber && !employee.idCardNumber) {
try {
const updated = { ...employee, idCardNumber }
localStorage.setItem('portalEmployee', JSON.stringify(updated))
} catch {}
}
return (
<div className="space-y-4">
{/* 欢迎卡片 */}
<Card className="bg-gradient-to-br from-indigo-600 to-indigo-700 text-white border-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{employee.name || overview?.employeeName || '同事'}</h1>
<p className="text-sm text-indigo-100 mt-1">{employee.department || ''} · {employee.position || ''}</p>
{idCardNumber && <p className="text-xs text-indigo-200 mt-0.5 font-mono">{idCardNumber}</p>}
</div>
<div className="text-right">
<div className="text-xs text-indigo-100"></div>
<div className="text-xl font-bold">¥{fmt(latestPayslip?.netPay || 0)}</div>
</div>
</div>
</Card>
{/* 待办提醒 */}
{pendingTasks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-700 flex items-center gap-1">
<AlertCircle className="w-4 h-4 text-amber-500" />
{pendingTasks.length}
</h2>
{pendingTasks.map((task: any, i: number) => (
<InlineAlert key={i} type={task.severity === 'high' ? 'error' : 'warning'} className="text-xs">
{task.message}
</InlineAlert>
))}
</div>
)}
{/* 快捷入口 */}
<div>
<h2 className="text-sm font-medium text-gray-700 mb-2"></h2>
<div className="grid grid-cols-4 gap-3">
{QUICK_ACTIONS.map((action) => {
const Icon = action.icon
return (
<Link
key={action.path}
to={action.path}
className="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-white border border-gray-100 hover:shadow-sm transition-shadow"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${action.color}`}>
<Icon className="w-5 h-5" />
</div>
<span className="text-xs text-gray-600">{action.label}</span>
</Link>
)
})}
</div>
</div>
{/* 最新工资条 */}
{latestPayslip && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<DollarSign className="w-4 h-4 text-emerald-500" />
</h2>
<Link to="/portal/payslip" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">{latestPayslip.month}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium">¥{fmt(latestPayslip.totalPay)}</div>
</div>
<div>
<div className="text-xs text-gray-400"></div>
<div className="font-medium text-emerald-600">¥{fmt(latestPayslip.netPay)}</div>
</div>
</div>
</Card>
)}
{/* 合同状态 */}
{contractInfo && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<FileText className="w-4 h-4 text-blue-500" />
</h2>
<Link to="/portal/contract" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium">{contractInfo.typeLabel || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium text-xs">{contractInfo.startDate} ~ {contractInfo.endDate || '无固定期'}</span>
</div>
{contractInfo.daysToExpire !== null && contractInfo.daysToExpire !== undefined && (
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className={`font-medium ${contractInfo.daysToExpire < 30 ? 'text-amber-600' : 'text-gray-700'}`}>
{contractInfo.daysToExpire > 0 ? `${contractInfo.daysToExpire}天后到期` : '已到期'}
</span>
</div>
)}
</div>
</Card>
)}
{/* 考勤概览 */}
{attendanceSummary && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1">
<CalendarCheck className="w-4 h-4 text-purple-500" />
</h2>
<Link to="/portal/attendance" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div className="p-2 rounded-lg bg-green-50">
<div className="text-lg font-bold text-emerald-600">{attendanceSummary.normalDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-amber-50">
<div className="text-lg font-bold text-amber-600">{attendanceSummary.lateCount || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-blue-50">
<div className="text-lg font-bold text-blue-600">{attendanceSummary.leaveDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="p-2 rounded-lg bg-gray-50">
<div className="text-lg font-bold text-gray-600">{attendanceSummary.absentDays || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
</Card>
)}
{/* 公司公告 */}
{announcements.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
<ScrollText className="w-4 h-4 text-gray-400" />
</h2>
<div className="space-y-2">
{announcements.slice(0, 3).map((ann: any, i: number) => (
<div key={i} className="flex items-start gap-2 p-2 rounded-md hover:bg-gray-50">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700 truncate">{ann.title}</div>
<div className="text-xs text-gray-400 mt-0.5">{ann.date} · {ann.author}</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</div>
))}
</div>
</Card>
)}
{/* 辞职申请入口 */}
<Card>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserX className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium"></span>
</div>
<Link to="/portal/resignation" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</Card>
</div>
)
}