Sprint 4-5: 员工自助+考勤+合规+AI+搜索

This commit is contained in:
selfrelease
2026-08-01 06:45:59 +08:00
parent 9946197d20
commit a88c96299c
16 changed files with 1919 additions and 35 deletions
+4
View File
@@ -25,6 +25,7 @@ enum Role {
enum EmployeeStatus {
ACTIVE
RESIGNED
TERMINATED
}
enum FemaleWorkerType {
@@ -202,7 +203,9 @@ model Employee {
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
employeeNo String? // 工号
department String
position String? // 岗位
hireDate DateTime
monthlySalary String // AES-256 加密存储
status EmployeeStatus @default(ACTIVE)
@@ -965,6 +968,7 @@ model AttendanceConfirmation {
holidayHours Float @default(0)
overtimePay Float @default(0)
confirmedAt DateTime?
confirmedBy String?
confirmIp String?
status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED
disputeNote String? // 员工有异议时的说明
+21
View File
@@ -105,6 +105,27 @@ router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response,
}
})
/** HR 批量确认考勤 */
router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
ids: z.array(z.string()).optional(),
all: z.boolean().optional(),
})
const { month, ids, all } = schema.parse(req.body)
const where: any = { orgId: req.user!.orgId, month, status: 'PENDING' }
if (!all && ids?.length) {
where.id = { in: ids }
}
const result = await prisma.attendanceConfirmation.updateMany({
where,
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) { next(err) }
})
// ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
+195
View File
@@ -640,4 +640,199 @@ router.get('/attendance', portalAuth, async (req: any, res, next) => {
}
})
// ========== 员工端:首页概览 ==========
router.get('/home/overview', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
// 最新工资条
const latestPayslip = await prisma.payslip.findFirst({
where: { employeeId, orgId, publishStatus: 'PUBLISHED' },
orderBy: { month: 'desc' },
select: { month: true, totalPay: true, netPay: true },
})
// 合同信息
const contract = await prisma.laborContract.findFirst({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
select: { contractType: true, startDate: true, endDate: true },
})
const typeLabels: Record<string, string> = { FIXED: '劳动合同-固定期', UNFIXED: '劳动合同-无固定期', LABOR: '劳务协议', INTERNSHIP: '实习协议', DISPATCH: '劳务派遣', OUTSOURCING: '业务外包', PARTTIME: '兼职协议', UNSIGNED: '未签合同' }
let daysToExpire: number | null = null
if (contract?.endDate) {
const diff = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
daysToExpire = diff
}
// 本月考勤概览
const month = new Date().toISOString().slice(0, 7)
const startDate = new Date(`${month}-01`)
const endDate = new Date(startDate)
endDate.setMonth(endDate.getMonth() + 1)
const attendanceRecords = await prisma.attendanceRecord.findMany({
where: { employeeId, orgId, date: { gte: startDate, lt: endDate } },
})
const attendanceSummary = {
normalDays: attendanceRecords.filter((r: any) => r.status === 'NORMAL').length,
lateCount: attendanceRecords.filter((r: any) => r.status === 'LATE').length,
leaveDays: attendanceRecords.filter((r: any) => r.status === 'LEAVE').length,
absentDays: attendanceRecords.filter((r: any) => r.status === 'ABSENT').length,
}
// 待办事项
const pendingTasks: any[] = []
if (contract && daysToExpire !== null && daysToExpire < 30 && daysToExpire >= 0) {
pendingTasks.push({ severity: 'high', message: `合同将在 ${daysToExpire} 天后到期,请联系HR确认续签事宜` })
}
if (contract && daysToExpire !== null && daysToExpire < 0) {
pendingTasks.push({ severity: 'high', message: '合同已到期,请尽快联系HR办理续签或离职手续' })
}
// 查找未阅读的制度:取所有制度ID,排除已阅读的
const allPolicies = await prisma.policyDocument.findMany({ where: { orgId }, select: { id: true } })
const readRecords = await prisma.policyReadRecord.findMany({
where: { employeeId, orgId },
select: { policyId: true },
})
const readPolicyIds = new Set(readRecords.map(r => r.policyId))
const unreadPolicies = allPolicies.filter(p => !readPolicyIds.has(p.id))
if (unreadPolicies.length > 0) {
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
}
res.json({
success: true,
data: {
latestPayslip,
contract: contract ? {
typeLabel: typeLabels[contract.contractType] || contract.contractType,
startDate: contract.startDate?.toISOString().slice(0, 10),
endDate: contract.endDate?.toISOString().slice(0, 10),
daysToExpire,
} : null,
attendance: attendanceSummary,
pendingTasks,
announcements: [],
},
})
} catch (err) { next(err) }
})
// ========== 员工端:入职进度 ==========
router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
const contract = await prisma.laborContract.findFirst({ where: { employeeId, orgId } })
const files = await prisma.employeeAttachment.findMany({ where: { employeeId, orgId } })
const steps: any = {
profile: {
completed: !!(employee.name && employee.idCardNumber && employee.phone),
description: employee.name ? '基本信息已填写' : '请完善基本信息',
completedAt: employee.hireDate,
},
documents: {
completed: files.length > 0,
description: files.length > 0 ? `已上传 ${files.length} 份材料` : '请上传入职材料',
},
contract: {
completed: !!contract,
description: contract ? '合同已签署' : '等待合同签署',
completedAt: contract?.createdAt,
},
bankcard: {
completed: !!(employee as any).bankCard,
description: (employee as any).bankCard ? '银行卡已登记' : '请登记银行卡信息',
},
complete: {
completed: employee.status === 'ACTIVE',
description: employee.status === 'ACTIVE' ? '入职流程已完成' : '入职流程进行中',
},
}
const completedCount = Object.values(steps).filter((s: any) => s.completed).length
const completionRate = Math.round((completedCount / 5) * 100)
const currentStepIndex = Object.values(steps).findIndex((s: any) => !s.completed)
const pendingItems: any[] = []
Object.entries(steps).forEach(([key, s]: any) => {
if (!s.completed) pendingItems.push({ message: s.description })
})
res.json({ success: true, data: { steps, completionRate, currentStepIndex, pendingItems } })
} catch (err) { next(err) }
})
// ========== 员工端:离职申请 ==========
// 提交离职申请
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const { reason, expectedDate, remark } = req.body
if (!reason || !expectedDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } })
}
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
if (employee.status === 'RESIGNED' || employee.status === 'TERMINATED') {
return res.status(400).json({ success: false, error: { code: 'ALREADY_RESIGNED', message: '您已离职,无法重复申请' } })
}
// 检查是否已有待审批的离职申请
const existing = await (prisma as any).terminationRecord.findFirst({
where: { employeeId, orgId, status: { in: ['DRAFT', 'PENDING_APPROVAL'] } },
})
if (existing) {
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } })
}
const record = await (prisma as any).terminationRecord.create({
data: {
employeeId, orgId,
reason: 'RESIGNATION',
terminationDate: new Date(expectedDate),
status: 'PENDING_APPROVAL',
remark: `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}`,
createdBy: employeeId,
},
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 查询自己的离职申请状态
router.get('/resignation/status', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const records = await (prisma as any).terminationRecord.findMany({
where: { employeeId, orgId },
orderBy: { createdAt: 'desc' },
take: 5,
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 撤回离职申请(仅 DRAFT/PENDING_APPROVAL 可撤回)
router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next) => {
try {
const { id: employeeId, orgId } = req.employee
const record = await (prisma as any).terminationRecord.findFirst({
where: { id: req.params.id, employeeId, orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职申请不存在' } })
if (record.status !== 'DRAFT' && record.status !== 'PENDING_APPROVAL') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '当前状态无法撤回' } })
}
await (prisma as any).terminationRecord.update({
where: { id: record.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: { id: record.id, status: 'CANCELLED' } })
} catch (err) { next(err) }
})
export default router
+51
View File
@@ -0,0 +1,51 @@
/**
* 全局搜索路由 — 员工、页面、功能搜索
*/
import { Router } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
/**
* GET /search?q=keyword
* 全局搜索:员工、部门等
*/
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const q = (req.query.q as string || '').trim()
if (!q || q.length < 1) {
return res.json({ success: true, data: { employees: [] } })
}
if (!req.user) {
return res.status(401).json({ success: false, message: '未授权' })
}
const orgId = req.user.orgId
// 搜索员工(按姓名、工号、手机号)
const employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ name: { contains: q } },
{ employeeNo: { contains: q } },
{ phone: { contains: q } },
],
status: { notIn: ['TERMINATED'] },
},
select: {
id: true,
name: true,
department: true,
position: true,
employeeNo: true,
},
take: 10,
})
res.json({ success: true, data: { employees } })
} catch (err) { next(err) }
})
export default router
+29 -1
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useState } from 'react'
import { lazy, Suspense, useState, useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner'
import { useAuthStore } from './store/authStore'
@@ -7,6 +7,7 @@ import SidebarNav from './components/layout/SidebarNav'
import MobileTabBar from './components/layout/MobileTabBar'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { CommandPalette } from './components/ui/CommandPalette'
import { SkeletonPage } from './components/ui/Skeleton'
import ErrorBoundary from './components/ui/ErrorBoundary'
@@ -41,6 +42,13 @@ const WorkProcess = lazy(() => import('./pages/WorkProcess'))
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
// Sprint 4-5 新增页面
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
// 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard'))
@@ -138,8 +146,23 @@ function PortalLayoutWrapper({ children, showNav = true }: { children: React.Rea
}
export default function App() {
const [cmdOpen, setCmdOpen] = useState(false)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
if (isAuthenticated) setCmdOpen(true)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isAuthenticated])
return (
<>
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} />
<Routes>
{/* 管理端认证页面 */}
<Route path="/login" element={<Suspense fallback={<SkeletonPage />}><PublicRoute><Login /></PublicRoute></Suspense>} />
@@ -166,6 +189,8 @@ export default function App() {
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
{/* 平台管理端 */}
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
@@ -182,6 +207,9 @@ export default function App() {
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
@@ -4,10 +4,11 @@
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck } from 'lucide-react'
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck, Home, UserX, ClipboardList } from 'lucide-react'
import Logo from '../../components/ui/Logo'
const tabItems = [
{ path: '/portal/home', label: '首页', icon: Home },
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
{ path: '/portal/contract', label: '我的合同', icon: FileText },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck },
@@ -8,7 +8,7 @@ import { useState } from 'react'
import clsx from 'clsx'
import {
LayoutDashboard, Users, CalendarCheck, UserX,
Calculator, Shield,
Calculator, Shield, BarChart3, ShieldAlert,
FileSearch, FileText, Stethoscope, HeartPulse, Award,
Bot, BookMarked,
Bell, ScrollText, Settings,
@@ -50,6 +50,7 @@ const navGroups: NavGroup[] = [
items: [
{ path: '/money', label: '薪税管理', icon: Calculator },
{ path: '/social', label: '社保公积金', icon: Shield },
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
],
},
{
@@ -61,6 +62,7 @@ const navGroups: NavGroup[] = [
{
title: '合规',
items: [
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
@@ -0,0 +1,180 @@
/**
* 上下文 AI 入口组件 — 嵌入业务页面,传递页面上下文给 AI 助手
* 支持浮动按钮 + 弹出对话框
*/
import { useState, useRef, useEffect } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Bot, Send, X, Sparkles } from 'lucide-react'
import api from '../../lib/api'
interface AIContextEntryProps {
/** 当前页面上下文标识 */
context: string
/** 上下文描述(传给后端 AI) */
contextData?: Record<string, any>
/** 页面标题 */
pageTitle: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
/**
* 上下文 AI 入口 — 浮动按钮 + 弹出式对话框
* 自动携带当前页面上下文信息
*/
export function AIContextEntry({ context, contextData, pageTitle }: AIContextEntryProps) {
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
/** 自动滚动到底部 */
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
/** AI 问答 */
const askMutation = useMutation({
mutationFn: async (question: string) => {
const res = await api.post('/ai/context-ask', {
context,
contextData,
pageTitle,
question,
history: messages.slice(-6),
}) as any
return res.data
},
onSuccess: (data) => {
setMessages(prev => [...prev, { role: 'assistant', content: data.answer || data.message || '抱歉,我暂时无法回答这个问题。' }])
},
onError: () => {
setMessages(prev => [...prev, { role: 'assistant', content: 'AI 服务暂时不可用,请稍后再试。' }])
},
})
const handleSend = () => {
if (!input.trim() || askMutation.isPending) return
const question = input.trim()
setMessages(prev => [...prev, { role: 'user', content: question }])
setInput('')
askMutation.mutate(question)
}
/** 预设问题 */
const presetQuestions: Record<string, string[]> = {
roster: ['哪些员工合同即将到期?', '如何批量导入员工?', '试用期员工有哪些风险?'],
termination: ['离职补偿金如何计算?', '什么情况属于违法解除?', '离职交接清单包含哪些?'],
social: ['社保基数如何确定?', '公积金缴存比例是多少?', '如何办理月度社保增减员?'],
money: ['工资条包含哪些项目?', '个税如何计算?', '如何批量发薪?'],
attendance: ['如何发布月度考勤?', '考勤异常如何处理?'],
}
const presets = presetQuestions[context] || []
return (
<>
{/* 浮动按钮 */}
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 w-12 h-12 rounded-full bg-primary text-white shadow-lg hover:shadow-xl transition-shadow flex items-center justify-center"
aria-label="询问 AI"
>
<Bot className="w-5 h-5" />
</button>
{/* 弹出对话框 */}
{open && (
<div className="fixed inset-0 z-50 flex items-end md:items-center justify-center bg-black/20 px-4 pb-4 md:pb-0" onClick={() => setOpen(false)}>
<div
className="w-full max-w-md bg-white rounded-xl shadow-2xl border border-gray-200 flex flex-col max-h-[80vh]"
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-primary" />
</div>
<div>
<div className="text-sm font-medium">AI · {pageTitle}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
<button onClick={() => setOpen(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
{/* 消息列表 */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[200px]">
{messages.length === 0 ? (
<div className="text-center py-6">
<Bot className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<div className="text-sm text-gray-400"> AI {pageTitle}</div>
{presets.length > 0 && (
<div className="mt-4 space-y-2">
{presets.map((q, i) => (
<button
key={i}
onClick={() => { setInput(q) }}
className="block w-full text-left text-xs text-primary bg-primary/5 hover:bg-primary/10 rounded-lg px-3 py-2 transition-colors"
>
{q}
</button>
))}
</div>
)}
</div>
) : (
messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
msg.role === 'user'
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-700'
}`}>
{msg.content}
</div>
</div>
))
)}
{askMutation.isPending && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-lg px-3 py-2 text-sm text-gray-400">
<span className="inline-flex gap-1">
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>·</span>
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>·</span>
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>·</span>
</span>
</div>
</div>
)}
</div>
{/* 输入区 */}
<div className="px-4 py-3 border-t border-gray-100 flex items-center gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSend() }}
placeholder="输入问题..."
className="flex-1 text-sm outline-none bg-gray-50 rounded-lg px-3 py-2 border border-gray-200 focus:border-primary focus:ring-2 focus:ring-primary/10 transition-colors"
/>
<button
onClick={handleSend}
disabled={!input.trim() || askMutation.isPending}
className="w-8 h-8 rounded-lg bg-primary text-white flex items-center justify-center disabled:opacity-40 transition-opacity"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,201 @@
/**
* CommandPalette 全局搜索 — Cmd/Ctrl+K 唤起,支持全局搜索和快捷导航
*/
import { useState, useEffect, useRef, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, ArrowRight, Clock } from 'lucide-react'
import api from '../../lib/api'
/** 搜索结果类型 */
interface SearchResult {
type: 'employee' | 'page' | 'action'
id: string
title: string
subtitle?: string
link: string
icon?: string
}
/** 快捷页面导航 */
const QUICK_PAGES: SearchResult[] = [
{ type: 'page', id: 'dashboard', title: '工作台', link: '/', icon: 'home' },
{ type: 'page', id: 'roster', title: '花名册', link: '/roster', icon: 'users' },
{ type: 'page', id: 'money', title: '薪税管理', link: '/money', icon: 'wallet' },
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
{ type: 'page', id: 'attendance', title: '考勤排班', link: '/attendance', icon: 'calendar' },
{ type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' },
{ type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
{ type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' },
{ type: 'page', id: 'settings', title: '设置', link: '/settings', icon: 'gear' },
]
interface CommandPaletteProps {
open: boolean
onClose: () => void
}
export function CommandPalette({ open, onClose }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
const navigate = useNavigate()
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
/** 搜索逻辑 */
useEffect(() => {
if (!open) {
setQuery('')
setSelectedIndex(0)
setSearchResults([])
return
}
// 聚焦输入框
setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
/** 执行搜索 */
useEffect(() => {
if (!query.trim()) {
setSearchResults([])
return
}
const q = query.trim().toLowerCase()
setSearching(true)
// 本地页面匹配
const localResults = QUICK_PAGES.filter(p =>
p.title.toLowerCase().includes(q)
)
// 远程搜索员工
const timer = setTimeout(async () => {
try {
const res = await api.get('/search', { params: { q } }) as any
const remoteResults: SearchResult[] = (res.data?.employees || []).map((e: any) => ({
type: 'employee' as const,
id: e.id,
title: e.name,
subtitle: `${e.department || ''} · ${e.position || ''}`,
link: `/roster?search=${encodeURIComponent(e.name)}`,
}))
setSearchResults([...localResults, ...remoteResults])
} catch {
setSearchResults(localResults)
} finally {
setSearching(false)
}
}, 300)
return () => clearTimeout(timer)
}, [query])
/** 合并结果(无搜索词时显示快捷页面) */
const displayResults = useMemo(() => {
if (!query.trim()) return QUICK_PAGES
return searchResults
}, [query, searchResults])
/** 键盘导航 */
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => Math.min(i + 1, displayResults.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
const result = displayResults[selectedIndex]
if (result) {
navigate(result.link)
onClose()
}
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, selectedIndex, displayResults, navigate, onClose])
/** 滚动到选中项 */
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/30 pt-[15vh] px-4"
onClick={onClose}
>
<div
className="w-full max-w-xl bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 搜索输入 */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100">
<Search className="w-4 h-4 text-gray-400 shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0) }}
placeholder="搜索员工、页面、功能..."
className="flex-1 text-sm outline-none bg-transparent"
/>
<kbd className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">ESC</kbd>
</div>
{/* 搜索结果 */}
<div ref={listRef} className="max-h-80 overflow-y-auto py-2">
{displayResults.length === 0 && !searching ? (
<div className="text-center py-8 text-sm text-gray-400">
{query ? '未找到匹配结果' : '输入关键词搜索'}
</div>
) : (
displayResults.map((result, i) => (
<button
key={`${result.type}-${result.id}`}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors ${
i === selectedIndex ? 'bg-primary/5' : 'hover:bg-gray-50'
}`}
onClick={() => { navigate(result.link); onClose() }}
onMouseEnter={() => setSelectedIndex(i)}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700 truncate">{result.title}</div>
{result.subtitle && (
<div className="text-xs text-gray-400 truncate">{result.subtitle}</div>
)}
</div>
{result.type === 'page' && <Clock className="w-3 h-3 text-gray-300" />}
<ArrowRight className="w-3 h-3 text-gray-300" />
</button>
))
)}
{searching && (
<div className="text-center py-2 text-xs text-gray-400">...</div>
)}
</div>
{/* 底部提示 */}
<div className="px-4 py-2 border-t border-gray-100 flex items-center justify-between text-xs text-gray-400">
<div className="flex items-center gap-3">
<span><kbd className="bg-gray-100 px-1 rounded"></kbd> </span>
<span><kbd className="bg-gray-100 px-1 rounded"></kbd> </span>
</div>
<span>{displayResults.length} </span>
</div>
</div>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* useSavedViews — 保存/加载筛选视图到 localStorage
* 支持多页面、多视图持久化
*/
import { useState, useCallback, useEffect } from 'react'
interface SavedView {
id: string
name: string
filters: Record<string, any>
createdAt: string
}
/**
* 保存视图 Hook — 将筛选条件持久化到 localStorage
* @param pageKey 页面唯一标识(如 'roster', 'money'
*/
export function useSavedViews(pageKey: string) {
const storageKey = `saved-views:${pageKey}`
const [views, setViews] = useState<SavedView[]>([])
const [activeViewId, setActiveViewId] = useState<string | null>(null)
/** 初始化加载 */
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
setViews(JSON.parse(stored))
}
} catch {
// 忽略解析错误
}
}, [storageKey])
/** 持久化保存 */
const persist = useCallback((newViews: SavedView[]) => {
setViews(newViews)
try {
localStorage.setItem(storageKey, JSON.stringify(newViews))
} catch {
// 存储满或不可用
}
}, [storageKey])
/** 保存当前筛选为视图 */
const saveView = useCallback((name: string, filters: Record<string, any>) => {
const id = `${Date.now()}`
const newView: SavedView = {
id,
name,
filters,
createdAt: new Date().toISOString(),
}
persist([...views, newView])
setActiveViewId(id)
return newView
}, [views, persist])
/** 删除视图 */
const deleteView = useCallback((id: string) => {
persist(views.filter(v => v.id !== id))
if (activeViewId === id) setActiveViewId(null)
}, [views, persist, activeViewId])
/** 应用视图 */
const applyView = useCallback((id: string) => {
const view = views.find(v => v.id === id)
if (view) {
setActiveViewId(id)
return view.filters
}
return null
}, [views])
/** 重命名视图 */
const renameView = useCallback((id: string, name: string) => {
persist(views.map(v => v.id === id ? { ...v, name } : v))
}, [views, persist])
return {
views,
activeViewId,
saveView,
deleteView,
applyView,
renameView,
}
}
+198 -32
View File
@@ -1,7 +1,7 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2 } from 'lucide-react'
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -9,6 +9,8 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import { InlineAlert } from '../components/ui/InlineAlert'
import { useConfirm } from '../hooks/useConfirm'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock },
@@ -90,19 +92,23 @@ export default function Attendance() {
// ========== 考勤确认 Tab ==========
function ConfirmTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [filterDepartment, setFilterDepartment] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['attendance', month, filterDepartment],
queryKey: ['attendance', month, filterDepartment, filterStatus],
queryFn: async () => {
const params: any = { month }
if (filterDepartment) params.department = filterDepartment
if (filterStatus) params.status = filterStatus
const res = await api.get('/attendance', { params }) as any
return res.data
},
@@ -156,38 +162,178 @@ function ConfirmTab() {
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消失败'),
})
const batchConfirmMutation = useMutation({
mutationFn: async (params: { all?: boolean; ids?: string[] }) => {
const res = await api.post('/attendance/batch-confirm', { month, ...params }) as any
return res.data
},
onSuccess: (data: any) => {
toast.success(`已批量确认 ${data.count} 条考勤记录`)
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
setSelectedIds(new Set())
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '批量确认失败'),
})
const singleConfirmMutation = useMutation({
mutationFn: async (id: string) => {
const res = await api.post('/attendance/confirm', { employeeId: list.find((i: any) => i.id === id)?.employeeId, month }) as any
return res.data
},
onSuccess: () => {
toast.success('已确认')
queryClient.invalidateQueries({ queryKey: ['attendance'] })
queryClient.invalidateQueries({ queryKey: ['attendance-stats'] })
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'),
})
const currentPublish = publishRecords?.find((r: any) => r.month === month && r.status === 'PUBLISHED')
const pendingCount = stats?.pending || 0
const pendingItems = (list || []).filter((i: any) => i.status === 'PENDING')
const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id))
const toggleSelect = (id: string) => {
const next = new Set(selectedIds)
if (next.has(id)) next.delete(id)
else next.add(id)
setSelectedIds(next)
}
const toggleSelectAllPending = () => {
if (allPendingSelected) {
const next = new Set(selectedIds)
pendingItems.forEach((i: any) => next.delete(i.id))
setSelectedIds(next)
} else {
const next = new Set(selectedIds)
pendingItems.forEach((i: any) => next.add(i.id))
setSelectedIds(next)
}
}
// 流程步骤
const FLOW_STEPS = [
{ label: '导入考勤', desc: 'Excel 批量导入', done: (list?.length || 0) > 0 },
{ label: 'HR 确认', desc: `待确认 ${pendingCount}`, done: pendingCount === 0 && (list?.length || 0) > 0 },
{ label: '发布考勤表', desc: currentPublish ? '已发布' : '未发布', done: !!currentPublish },
{ label: '员工确认', desc: stats ? `已确认 ${stats.confirmed}/${stats.total}` : '', done: stats?.confirmed === stats?.total && stats?.total > 0 },
]
return (
<div className="space-y-3">
<div className="flex items-center gap-2 justify-end">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={() => cancelPublishMutation.mutate(currentPublish.id)}>
<X className="w-3.5 h-3.5 mr-1" />
{/* 流程指示器 */}
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{FLOW_STEPS.map((step, i) => (
<div key={i} className="flex items-center gap-1 shrink-0">
<div className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs ${step.done ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-500'}`}>
{step.done ? <CheckCircle className="w-3.5 h-3.5" /> : <Clock className="w-3.5 h-3.5" />}
<span className="font-medium">{step.label}</span>
<span className="text-gray-400">{step.desc}</span>
</div>
{i < FLOW_STEPS.length - 1 && <span className="text-gray-300"></span>}
</div>
))}
</div>
{/* 指引提示 */}
{!currentPublish && pendingCount > 0 && (
<InlineAlert type="info" title="考勤确认流程">
</InlineAlert>
)}
{currentPublish && (
<InlineAlert type="success" title="考勤表已发布">
{month} {stats?.confirmed || 0}/{stats?.total || 0}
</InlineAlert>
)}
{stats?.disputed > 0 && (
<InlineAlert type="warning" title="有员工提出异议">
{stats.disputed}
</InlineAlert>
)}
<div className="flex items-center gap-2 justify-between flex-wrap">
<div className="flex items-center gap-2">
{pendingCount > 0 && (
<>
<Button
size="sm"
variant="secondary"
onClick={toggleSelectAllPending}
>
{allPendingSelected ? '取消全选' : '全选待确认'}
</Button>
{selectedIds.size > 0 && (
<Button
size="sm"
onClick={async () => {
const ok = await confirm({ title: '批量确认考勤', message: `确认将选中的 ${selectedIds.size} 条考勤记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ ids: Array.from(selectedIds) })
}}
disabled={batchConfirmMutation.isPending}
>
<CheckCheck className="w-3.5 h-3.5 mr-1" />
({selectedIds.size})
</Button>
)}
<Button
size="sm"
variant="secondary"
onClick={async () => {
const ok = await confirm({ title: '全部确认', message: `确认将全部 ${pendingCount} 条待确认记录标记为已确认?` })
if (ok) batchConfirmMutation.mutate({ all: true })
}}
disabled={batchConfirmMutation.isPending}
>
({pendingCount})
</Button>
</>
)}
</div>
<div className="flex items-center gap-2">
{currentPublish ? (
<Button size="sm" variant="secondary" onClick={async () => {
const ok = await confirm({ title: '取消发布', message: '取消发布后员工端将无法查看该月考勤表,确定操作?' })
if (ok) cancelPublishMutation.mutate(currentPublish.id)
}}>
<X className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending || pendingCount > 0}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
) : (
<Button size="sm" onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Send className="w-3.5 h-3.5 mr-1" />}
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
<option value="PENDING"></option>
<option value="CONFIRMED"></option>
<option value="DISPUTED"></option>
</select>
<select
value={filterDepartment}
onChange={e => setFilterDepartment(e.target.value)}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
>
<option value=""></option>
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
</select>
<input
type="month"
value={month}
onChange={e => setMonth(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{stats && (
@@ -215,10 +361,19 @@ function ConfirmTab() {
{list.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
return (
<Card key={item.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
{item.status === 'PENDING' && (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(item.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary shrink-0"
/>
)}
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-50 flex-shrink-0">
<CalendarCheck className="w-4 h-4 text-gray-600" />
</div>
@@ -239,9 +394,20 @@ function ConfirmTab() {
)}
</div>
</div>
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
<div className="flex items-center gap-2 flex-shrink-0">
{item.status === 'PENDING' && (
<button
className="text-xs text-primary hover:underline"
onClick={() => singleConfirmMutation.mutate(item.id)}
disabled={singleConfirmMutation.isPending}
>
</button>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{config.label}</span>
</div>
</div>
</div>
</Card>
+179
View File
@@ -0,0 +1,179 @@
/**
* 薪酬分析看板 — 展示薪酬分布、部门对比、同比环比趋势
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import api from '../lib/api'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
const res = await api.get('/salary/dashboard', { params: { year } }) as any
return res.data
},
})
const departments = data?.departments || []
const monthlyTrend = data?.monthlyTrend || []
const summary = data?.summary || {}
/** 计算最大值用于柱状图比例 */
const maxDeptAvg = useMemo(() => {
if (departments.length === 0) return 1
return Math.max(...departments.map((d: any) => d.avgSalary || 0), 1)
}, [departments])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
<Select value={year} onChange={(e) => setYear(e.target.value)} className="!w-24">
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !data ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<>
{/* 概览卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card className="p-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">{summary.totalEmployees || 0}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-emerald-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.avgSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-purple-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.medianSalary)}</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-2">
<Wallet className="w-4 h-4 text-amber-500" />
<span className="text-xs text-gray-400"></span>
</div>
<div className="text-2xl font-bold mt-1">¥{fmt(summary.totalAnnual)}</div>
</Card>
</div>
{/* 同比环比 */}
{summary.yoy !== undefined && (
<div className="flex gap-3">
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.yoy >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.yoy >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}%
</div>
</Card>
<Card className="flex-1 p-3">
<div className="text-xs text-gray-400"></div>
<div className={`text-lg font-bold mt-1 flex items-center gap-1 ${summary.mom >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{summary.mom >= 0 ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}%
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{departments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{departments.map((dept: any) => (
<div key={dept.name}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-600">{dept.name}</span>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{dept.count}</span>
<span className="font-medium text-gray-700">¥{fmt(dept.avgSalary)}</span>
</div>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${(dept.avgSalary / maxDeptAvg) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</Card>
{/* 月度趋势 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="flex items-end gap-2 h-40">
{monthlyTrend.map((m: any) => {
const maxVal = Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
const height = ((m.total || 0) / maxVal) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="text-xs text-gray-400">{m.total ? `¥${(m.total / 10000).toFixed(1)}` : ''}</div>
<div className="w-full bg-gray-100 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-primary/70 rounded-t-md transition-all hover:bg-primary"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-xs text-gray-400">{m.month}</div>
</div>
)
})}
</div>
)}
</Card>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
</InlineAlert>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,206 @@
/**
* 统一风险中心 — 汇总展示合同风险、薪酬风险、社保风险、合规风险
* 按风险等级分类,支持快速跳转处理
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { InlineAlert } from '../../components/ui/InlineAlert'
import api from '../../lib/api'
/** 风险等级配置 */
const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }> = {
HIGH: { label: '高风险', color: 'text-rose-600', bg: 'bg-rose-50 border-rose-200' },
MEDIUM: { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50 border-amber-200' },
LOW: { label: '低风险', color: 'text-blue-600', bg: 'bg-blue-50 border-blue-200' },
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT_EXPIRE: { label: '合同到期', icon: FileText, link: '/roster' },
CONTRACT_UNSIGNED: { label: '未签合同', icon: FileText, link: '/roster' },
PROBATION_EXPIRE: { label: '试用期到期', icon: Clock, link: '/roster' },
SALARY_BELOW_MIN: { label: '工资低于最低标准', icon: TrendingDown, link: '/money' },
SOCIAL_INSURANCE_GAP: { label: '社保断缴', icon: ShieldAlert, link: '/social' },
TERMINATION_RISK: { label: '离职风险', icon: Users, link: '/termination' },
POLICY_UNREAD: { label: '制度未阅读', icon: FileText, link: '/policies' },
}
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
/** 获取风险列表 */
const { data: risks = [], isLoading } = useQuery<any[]>({
queryKey: ['risk-center'],
queryFn: async () => {
const res = await api.get('/compliance/risks') as any
return res.data || []
},
})
/** 按级别统计 */
const stats = useMemo(() => {
const high = risks.filter((r: any) => r.level === 'HIGH').length
const medium = risks.filter((r: any) => r.level === 'MEDIUM').length
const low = risks.filter((r: any) => r.level === 'LOW').length
return { high, medium, low, total: risks.length }
}, [risks])
/** 按类型统计 */
const typeStats = useMemo(() => {
const map: Record<string, number> = {}
risks.forEach((r: any) => {
map[r.type] = (map[r.type] || 0) + 1
})
return map
}, [risks])
/** 筛选后的风险列表 */
const filteredRisks = useMemo(() => {
return risks.filter((r: any) => {
if (filterLevel !== 'ALL' && r.level !== filterLevel) return false
if (filterType !== 'ALL' && r.type !== filterType) return false
return true
})
}, [risks, filterLevel, filterType])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
</div>
{/* 风险概览卡片 */}
<div className="grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-rose-600 mt-1">{stats.high}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-amber-600 mt-1">{stats.medium}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-blue-600 mt-1">{stats.low}</div>
</Card>
</div>
{/* 高风险告警 */}
{stats.high > 0 && (
<InlineAlert type="error">
{stats.high}
</InlineAlert>
)}
{/* 风险类型分布 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(RISK_TYPES).map(([key, cfg]) => {
const count = typeStats[key] || 0
if (count === 0) return null
const Icon = cfg.icon
return (
<Link
key={key}
to={cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600 truncate">{cfg.label}</div>
<div className="text-sm font-bold text-gray-900">{count}</div>
</div>
<ChevronRight className="w-3 h-3 text-gray-300" />
</Link>
)
})}
{Object.values(typeStats).every((v) => v === 0) && (
<div className="col-span-full text-center py-4 text-sm text-gray-400"></div>
)}
</div>
</Card>
{/* 筛选器 */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Filter className="w-4 h-4" />
</div>
<div className="flex gap-1">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel('ALL')}
></button>
{Object.entries(RISK_LEVELS).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel(key)}
>{cfg.label}</button>
))}
</div>
</div>
{/* 风险列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : filteredRisks.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
return (
<Link
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.message}</div>
{r.employeeName && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employeeName} · {r.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
)
})}
</div>
)}
</Card>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
/**
* 员工 Hub 首页 — 员工端统一入口
* 展示个人概览、待办事项、快捷入口、公司公告
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText,
TrendingUp, Clock, AlertCircle, ChevronRight,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 员工端 API 实例(自动携带 portalToken */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 金额格式化 */
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/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-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 () => {
const res = await portalApi.get('/home/overview') as any
return res.data
},
})
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
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 || '同事'}</h1>
<p className="text-sm text-indigo-100 mt-1">{employee.department || ''} · {employee.position || ''}</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.grossPay)}</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>
)}
</div>
)
}
@@ -0,0 +1,128 @@
/**
* 入职进度面板 — 员工端查看入职流程完成状态
* 展示入职步骤进度、材料提交状态、待完成项
*/
import { useQuery } from '@tanstack/react-query'
import { Check, Clock, AlertCircle, FileText, Upload, User, Phone, Banknote } from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
import { Stepper } from '../../components/ui/Stepper'
/** 员工端 API 实例 */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 入职步骤定义 */
const ONBOARDING_STEPS = [
{ key: 'profile', title: '基本信息', icon: User },
{ key: 'documents', title: '材料上传', icon: Upload },
{ key: 'contract', title: '合同签署', icon: FileText },
{ key: 'bankcard', title: '银行卡登记', icon: Banknote },
{ key: 'complete', title: '入职完成', icon: Check },
]
export default function OnboardingProgress() {
/** 获取入职进度数据 */
const { data: progress, isLoading } = useQuery<any>({
queryKey: ['portal-onboarding-progress'],
queryFn: async () => {
const res = await portalApi.get('/onboarding/progress') as any
return res.data
},
})
if (isLoading) {
return <div className="space-y-4">
<div className="h-24 bg-gray-100 rounded-xl animate-pulse" />
<div className="h-48 bg-gray-100 rounded-xl animate-pulse" />
</div>
}
if (!progress) {
return <Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
}
const steps = ONBOARDING_STEPS.map((s, i) => {
const stepData = progress.steps?.[s.key]
const status: 'complete' | 'current' | 'pending' =
stepData?.completed ? 'complete' :
i === progress.currentStepIndex ? 'current' : 'pending'
return { key: s.key, title: s.title, status, description: stepData?.description }
})
const completionRate = progress.completionRate || 0
const pendingItems = progress.pendingItems || []
return (
<div className="space-y-4">
{/* 进度概览 */}
<Card className="bg-gradient-to-br from-indigo-600 to-indigo-700 text-white border-0">
<div className="text-center">
<div className="text-3xl font-bold">{completionRate}%</div>
<div className="text-sm text-indigo-100 mt-1"></div>
<div className="mt-3 h-2 bg-indigo-800/50 rounded-full overflow-hidden">
<div className="h-full bg-white rounded-full transition-all" style={{ width: `${completionRate}%` }} />
</div>
</div>
</Card>
{/* 待办提醒 */}
{pendingItems.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" />
{pendingItems.length}
</h2>
{pendingItems.map((item: any, i: number) => (
<InlineAlert key={i} type="warning" className="text-xs">
{item.message}
</InlineAlert>
))}
</div>
)}
{/* 步骤进度条 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<Stepper steps={steps} orientation="vertical" />
</Card>
{/* 各步骤详情 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="space-y-3">
{ONBOARDING_STEPS.map((step) => {
const stepData = progress.steps?.[step.key]
const Icon = step.icon
const completed = stepData?.completed
return (
<div key={step.key} className="flex items-start gap-3 p-3 rounded-lg border border-gray-100">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${
completed ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-50 text-gray-400'
}`}>
{completed ? <Check className="w-4 h-4" /> : <Icon className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">{step.title}</div>
{stepData?.description && (
<div className="text-xs text-gray-400 mt-0.5">{stepData.description}</div>
)}
{stepData?.completedAt && (
<div className="text-xs text-emerald-500 mt-0.5 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(stepData.completedAt).toLocaleDateString('zh-CN')}
</div>
)}
</div>
</div>
)
})}
</div>
</Card>
</div>
)
}
@@ -0,0 +1,202 @@
/**
* 员工离职申请入口 — 员工端提交离职申请、查看申请状态、撤回申请
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, Check, X, FileText } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import { InlineAlert } from '../../components/ui/InlineAlert'
/** 员工端 API 实例 */
const portalApi = (await import('../../lib/api')).default.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
/** 离职原因选项 */
const RESIGN_REASONS = [
{ value: '个人发展', label: '个人发展' },
{ value: '薪资待遇', label: '薪资待遇' },
{ value: '家庭原因', label: '家庭原因' },
{ value: '健康原因', label: '健康原因' },
{ value: '工作环境', label: '工作环境' },
{ value: '其他', label: '其他' },
]
/** 状态映射 */
const STATUS_MAP: Record<string, { label: string; color: string }> = {
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-400' },
}
export default function ResignationApply() {
const queryClient = useQueryClient()
const [form, setForm] = useState({
reason: '',
expectedDate: '',
remark: '',
})
/** 查询离职申请状态 */
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-resignation-status'],
queryFn: async () => {
const res = await portalApi.get('/resignation/status') as any
return res.data || []
},
})
/** 提交离职申请 */
const submitMutation = useMutation({
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
const res = await portalApi.post('/resignation/submit', data) as any
return res.data
},
onSuccess: () => {
toast.success('离职申请已提交,请等待HR审批')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
setForm({ reason: '', expectedDate: '', remark: '' })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '提交失败')
},
})
/** 撤回离职申请 */
const withdrawMutation = useMutation({
mutationFn: async (id: string) => {
const res = await portalApi.post(`/resignation/${id}/withdraw`) as any
return res.data
},
onSuccess: () => {
toast.success('离职申请已撤回')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '撤回失败')
},
})
const handleSubmit = () => {
if (!form.reason) { toast.error('请选择离职原因'); return }
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
submitMutation.mutate(form)
}
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<UserX className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<InlineAlert type="info">
HR将在3个工作日内审批30
</InlineAlert>
{/* 申请表单 */}
{!hasPending ? (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label> *</Label>
<Select value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
<option value=""></option>
{RESIGN_REASONS.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</Select>
</div>
<div>
<Label> *</Label>
<Input
type="date"
value={form.expectedDate}
min={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}
onChange={(e) => setForm({ ...form, expectedDate: e.target.value })}
/>
<div className="text-xs text-gray-400 mt-1">30</div>
</div>
<div>
<Label></Label>
<Input
value={form.remark}
onChange={(e) => setForm({ ...form, remark: e.target.value })}
placeholder="补充说明(选填)"
/>
</div>
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
</Button>
</div>
</Card>
) : (
<InlineAlert type="warning">
</InlineAlert>
)}
{/* 申请记录 */}
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1">
<FileText className="w-4 h-4 text-gray-400" />
</h2>
{isLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : records.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="space-y-3">
{records.map((r: any) => {
const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT
const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL'
return (
<div key={r.id} className="p-3 rounded-lg border border-gray-100">
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
<span className="text-xs text-gray-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</span>
</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-400"></span>
<span className="font-medium">{r.terminationDate ? new Date(r.terminationDate).toLocaleDateString('zh-CN') : '—'}</span>
</div>
{r.remark && (
<div className="text-xs text-gray-500 mt-1">{r.remark}</div>
)}
</div>
{canWithdraw && (
<div className="mt-2 pt-2 border-t border-gray-50">
<Button
variant="secondary"
size="sm"
onClick={() => withdrawMutation.mutate(r.id)}
disabled={withdrawMutation.isPending}
>
{withdrawMutation.isPending ? '撤回中...' : '撤回申请'}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</Card>
</div>
)
}