Sprint 4-5: 员工自助+考勤+合规+AI+搜索
This commit is contained in:
@@ -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? // 员工有异议时的说明
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user