diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 522389b..90eed69 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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? // 员工有异议时的说明 diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index c4404f8..c08e9e0 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -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) => { diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index b69b6d6..87e6249 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -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 = { 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 diff --git a/backend/src/routes/search.routes.ts b/backend/src/routes/search.routes.ts new file mode 100644 index 0000000..715a677 --- /dev/null +++ b/backend/src/routes/search.routes.ts @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b5936d..85e39f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( <> + setCmdOpen(false)} /> {/* 管理端认证页面 */} }>} /> @@ -166,6 +189,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 平台管理端 */} }>} /> @@ -182,6 +207,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/components/layout/PortalLayout.tsx b/frontend/src/components/layout/PortalLayout.tsx index a776e0b..c6c7fa2 100644 --- a/frontend/src/components/layout/PortalLayout.tsx +++ b/frontend/src/components/layout/PortalLayout.tsx @@ -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 }, diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 5825428..20402b2 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -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 }, diff --git a/frontend/src/components/ui/AIContextEntry.tsx b/frontend/src/components/ui/AIContextEntry.tsx new file mode 100644 index 0000000..313491b --- /dev/null +++ b/frontend/src/components/ui/AIContextEntry.tsx @@ -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 + /** 页面标题 */ + 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([]) + const [input, setInput] = useState('') + const scrollRef = useRef(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 = { + roster: ['哪些员工合同即将到期?', '如何批量导入员工?', '试用期员工有哪些风险?'], + termination: ['离职补偿金如何计算?', '什么情况属于违法解除?', '离职交接清单包含哪些?'], + social: ['社保基数如何确定?', '公积金缴存比例是多少?', '如何办理月度社保增减员?'], + money: ['工资条包含哪些项目?', '个税如何计算?', '如何批量发薪?'], + attendance: ['如何发布月度考勤?', '考勤异常如何处理?'], + } + const presets = presetQuestions[context] || [] + + return ( + <> + {/* 浮动按钮 */} + + + {/* 弹出对话框 */} + {open && ( +
setOpen(false)}> +
e.stopPropagation()} + > + {/* 头部 */} +
+
+
+ +
+
+
AI 顾问 · {pageTitle}
+
基于当前页面上下文回答
+
+
+ +
+ + {/* 消息列表 */} +
+ {messages.length === 0 ? ( +
+ +
向 AI 提问关于「{pageTitle}」的问题
+ {presets.length > 0 && ( +
+ {presets.map((q, i) => ( + + ))} +
+ )} +
+ ) : ( + messages.map((msg, i) => ( +
+
+ {msg.content} +
+
+ )) + )} + {askMutation.isPending && ( +
+
+ + · + · + · + +
+
+ )} +
+ + {/* 输入区 */} +
+ 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" + /> + +
+
+
+ )} + + ) +} diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx new file mode 100644 index 0000000..0a5a63a --- /dev/null +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -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([]) + const [searching, setSearching] = useState(false) + const navigate = useNavigate() + const inputRef = useRef(null) + const listRef = useRef(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 ( +
+
e.stopPropagation()} + > + {/* 搜索输入 */} +
+ + { setQuery(e.target.value); setSelectedIndex(0) }} + placeholder="搜索员工、页面、功能..." + className="flex-1 text-sm outline-none bg-transparent" + /> + ESC +
+ + {/* 搜索结果 */} +
+ {displayResults.length === 0 && !searching ? ( +
+ {query ? '未找到匹配结果' : '输入关键词搜索'} +
+ ) : ( + displayResults.map((result, i) => ( + + )) + )} + {searching && ( +
搜索中...
+ )} +
+ + {/* 底部提示 */} +
+
+ ↑↓ 导航 + 选择 +
+ {displayResults.length} 个结果 +
+
+
+ ) +} diff --git a/frontend/src/hooks/useSavedViews.ts b/frontend/src/hooks/useSavedViews.ts new file mode 100644 index 0000000..b230f51 --- /dev/null +++ b/frontend/src/hooks/useSavedViews.ts @@ -0,0 +1,88 @@ +/** + * useSavedViews — 保存/加载筛选视图到 localStorage + * 支持多页面、多视图持久化 + */ +import { useState, useCallback, useEffect } from 'react' + +interface SavedView { + id: string + name: string + filters: Record + createdAt: string +} + +/** + * 保存视图 Hook — 将筛选条件持久化到 localStorage + * @param pageKey 页面唯一标识(如 'roster', 'money') + */ +export function useSavedViews(pageKey: string) { + const storageKey = `saved-views:${pageKey}` + const [views, setViews] = useState([]) + const [activeViewId, setActiveViewId] = useState(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) => { + 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, + } +} diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index 7278e20..13a6e95 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -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 = { 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(null) const [importResult, setImportResult] = useState(null) const [importing, setImporting] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) const fileInputRef = useRef(null) const { data: list, isLoading } = useQuery({ - 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 (
-
- {currentPublish ? ( - + {selectedIds.size > 0 && ( + + )} + + + )} +
+
+ {currentPublish ? ( + + ) : ( + + )} + - ) : ( - - )} - - - setMonth(e.target.value)} - className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> + + + setMonth(e.target.value)} + className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
{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 (
+ {item.status === 'PENDING' && ( + toggleSelect(item.id)} + className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary shrink-0" + /> + )}
@@ -239,9 +394,20 @@ function ConfirmTab() { )}
-
- - {config.label} +
+ {item.status === 'PENDING' && ( + + )} +
+ + {config.label} +
diff --git a/frontend/src/pages/SalaryDashboard.tsx b/frontend/src/pages/SalaryDashboard.tsx new file mode 100644 index 0000000..9a9b28e --- /dev/null +++ b/frontend/src/pages/SalaryDashboard.tsx @@ -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({ + 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 ( +
+ {/* 页头 */} +
+
+ +
+

薪酬分析看板

+

薪酬分布、部门对比、趋势分析

+
+
+ +
+ + {isLoading ? ( +
加载中...
+ ) : !data ? ( +
暂无数据
+ ) : ( + <> + {/* 概览卡片 */} +
+ +
+ + 员工总数 +
+
{summary.totalEmployees || 0}
+
+ +
+ + 月均薪酬 +
+
¥{fmt(summary.avgSalary)}
+
+ +
+ + 薪酬中位数 +
+
¥{fmt(summary.medianSalary)}
+
+ +
+ + 年度总薪酬 +
+
¥{fmt(summary.totalAnnual)}
+
+
+ + {/* 同比环比 */} + {summary.yoy !== undefined && ( +
+ +
同比增长率
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> + {summary.yoy >= 0 ? : } + {summary.yoy >= 0 ? '+' : ''}{(summary.yoy || 0).toFixed(1)}% +
+
+ +
环比增长率
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> + {summary.mom >= 0 ? : } + {summary.mom >= 0 ? '+' : ''}{(summary.mom || 0).toFixed(1)}% +
+
+
+ )} + + {/* 部门薪酬对比 */} + +

部门薪酬对比

+ {departments.length === 0 ? ( +
暂无部门数据
+ ) : ( +
+ {departments.map((dept: any) => ( +
+
+ {dept.name} +
+ {dept.count}人 + ¥{fmt(dept.avgSalary)} +
+
+
+
+
+
+ ))} +
+ )} + + + {/* 月度趋势 */} + +

月度薪酬趋势

+ {monthlyTrend.length === 0 ? ( +
暂无月度数据
+ ) : ( +
+ {monthlyTrend.map((m: any) => { + const maxVal = Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1) + const height = ((m.total || 0) / maxVal) * 100 + return ( +
+
{m.total ? `¥${(m.total / 10000).toFixed(1)}万` : ''}
+
+
+
+
{m.month}
+
+ ) + })} +
+ )} + + + {summary.totalEmployees === 0 && ( + + 当前年度暂无薪酬数据,请确保已发布发薪批次。 + + )} + + )} +
+ ) +} diff --git a/frontend/src/pages/compliance/RiskCenter.tsx b/frontend/src/pages/compliance/RiskCenter.tsx new file mode 100644 index 0000000..f8c67f4 --- /dev/null +++ b/frontend/src/pages/compliance/RiskCenter.tsx @@ -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 = { + 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 = { + 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('ALL') + const [filterType, setFilterType] = useState('ALL') + + /** 获取风险列表 */ + const { data: risks = [], isLoading } = useQuery({ + 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 = {} + 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 ( +
+ {/* 页头 */} +
+
+ +
+

统一风险中心

+

汇总合同、薪酬、社保、合规风险

+
+
+
+ + {/* 风险概览卡片 */} +
+ +
总风险
+
{stats.total}
+
+ +
高风险
+
{stats.high}
+
+ +
中风险
+
{stats.medium}
+
+ +
低风险
+
{stats.low}
+
+
+ + {/* 高风险告警 */} + {stats.high > 0 && ( + + 当前有 {stats.high} 项高风险事项需要立即处理,请尽快跟进。 + + )} + + {/* 风险类型分布 */} + +

风险类型分布

+
+ {Object.entries(RISK_TYPES).map(([key, cfg]) => { + const count = typeStats[key] || 0 + if (count === 0) return null + const Icon = cfg.icon + return ( + + +
+
{cfg.label}
+
{count}
+
+ + + ) + })} + {Object.values(typeStats).every((v) => v === 0) && ( +
暂无风险项
+ )} +
+
+ + {/* 筛选器 */} +
+
+ + 筛选 +
+
+ + {Object.entries(RISK_LEVELS).map(([key, cfg]) => ( + + ))} +
+
+ + {/* 风险列表 */} + + {isLoading ? ( +
加载中...
+ ) : filteredRisks.length === 0 ? ( +
+ {risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'} +
+ ) : ( +
+ {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 ( + + +
+
+ {levelCfg.label} + {typeCfg.label} +
+
{r.message}
+ {r.employeeName && ( +
+ {r.employeeName} · {r.department || ''} +
+ )} +
+ + + ) + })} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/portal/EmployeeHome.tsx b/frontend/src/pages/portal/EmployeeHome.tsx new file mode 100644 index 0000000..db4ddba --- /dev/null +++ b/frontend/src/pages/portal/EmployeeHome.tsx @@ -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({ + queryKey: ['portal-home-overview'], + queryFn: async () => { + const res = await portalApi.get('/home/overview') as any + return res.data + }, + }) + + if (isLoading) { + return
+
+
+
+
+ } + + const latestPayslip = overview?.latestPayslip + const contractInfo = overview?.contract + const pendingTasks = overview?.pendingTasks || [] + const announcements = overview?.announcements || [] + const attendanceSummary = overview?.attendance + + return ( +
+ {/* 欢迎卡片 */} + +
+
+

你好,{employee.name || '同事'}

+

{employee.department || ''} · {employee.position || ''}

+
+
+
本月实发
+
¥{fmt(latestPayslip?.netPay || 0)}
+
+
+
+ + {/* 待办提醒 */} + {pendingTasks.length > 0 && ( +
+

+ + 待办事项({pendingTasks.length}) +

+ {pendingTasks.map((task: any, i: number) => ( + + {task.message} + + ))} +
+ )} + + {/* 快捷入口 */} +
+

快捷入口

+
+ {QUICK_ACTIONS.map((action) => { + const Icon = action.icon + return ( + +
+ +
+ {action.label} + + ) + })} +
+
+ + {/* 最新工资条 */} + {latestPayslip && ( + +
+

+ + 最新工资条 +

+ + 查看全部 + +
+
+
+
月份
+
{latestPayslip.month}
+
+
+
应发合计
+
¥{fmt(latestPayslip.grossPay)}
+
+
+
实发合计
+
¥{fmt(latestPayslip.netPay)}
+
+
+
+ )} + + {/* 合同状态 */} + {contractInfo && ( + +
+

+ + 合同信息 +

+ + 详情 + +
+
+
+ 合同类型 + {contractInfo.typeLabel || '—'} +
+
+ 合同期间 + {contractInfo.startDate} ~ {contractInfo.endDate || '无固定期'} +
+ {contractInfo.daysToExpire !== null && contractInfo.daysToExpire !== undefined && ( +
+ 到期天数 + + {contractInfo.daysToExpire > 0 ? `${contractInfo.daysToExpire}天后到期` : '已到期'} + +
+ )} +
+
+ )} + + {/* 考勤概览 */} + {attendanceSummary && ( + +
+

+ + 本月考勤 +

+ + 详情 + +
+
+
+
{attendanceSummary.normalDays || 0}
+
正常
+
+
+
{attendanceSummary.lateCount || 0}
+
迟到
+
+
+
{attendanceSummary.leaveDays || 0}
+
请假
+
+
+
{attendanceSummary.absentDays || 0}
+
缺勤
+
+
+
+ )} + + {/* 公司公告 */} + {announcements.length > 0 && ( + +

+ + 公司公告 +

+
+ {announcements.slice(0, 3).map((ann: any, i: number) => ( +
+
+
{ann.title}
+
{ann.date} · {ann.author}
+
+ +
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/portal/OnboardingProgress.tsx b/frontend/src/pages/portal/OnboardingProgress.tsx new file mode 100644 index 0000000..f3bd2d8 --- /dev/null +++ b/frontend/src/pages/portal/OnboardingProgress.tsx @@ -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({ + queryKey: ['portal-onboarding-progress'], + queryFn: async () => { + const res = await portalApi.get('/onboarding/progress') as any + return res.data + }, + }) + + if (isLoading) { + return
+
+
+
+ } + + if (!progress) { + return
暂无入职进度信息
+ } + + 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 ( +
+ {/* 进度概览 */} + +
+
{completionRate}%
+
入职完成度
+
+
+
+
+ + + {/* 待办提醒 */} + {pendingItems.length > 0 && ( +
+

+ + 待完成项({pendingItems.length}) +

+ {pendingItems.map((item: any, i: number) => ( + + {item.message} + + ))} +
+ )} + + {/* 步骤进度条 */} + +

入职流程进度

+ +
+ + {/* 各步骤详情 */} + +

步骤详情

+
+ {ONBOARDING_STEPS.map((step) => { + const stepData = progress.steps?.[step.key] + const Icon = step.icon + const completed = stepData?.completed + return ( +
+
+ {completed ? : } +
+
+
{step.title}
+ {stepData?.description && ( +
{stepData.description}
+ )} + {stepData?.completedAt && ( +
+ + 完成于 {new Date(stepData.completedAt).toLocaleDateString('zh-CN')} +
+ )} +
+
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/pages/portal/ResignationApply.tsx b/frontend/src/pages/portal/ResignationApply.tsx new file mode 100644 index 0000000..495f28c --- /dev/null +++ b/frontend/src/pages/portal/ResignationApply.tsx @@ -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 = { + 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({ + 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 ( +
+
+ +

离职申请

+
+ + + 提交离职申请后,HR将在3个工作日内审批。提前30天提交为法定要求,请合理选择离职日期。 + + + {/* 申请表单 */} + {!hasPending ? ( + +

填写离职申请

+
+
+ + +
+
+ + setForm({ ...form, expectedDate: e.target.value })} + /> +
法定要求提前30天通知
+
+
+ + setForm({ ...form, remark: e.target.value })} + placeholder="补充说明(选填)" + /> +
+ +
+
+ ) : ( + + 您已有一个待处理的离职申请,请等待审批结果或撤回后重新提交。 + + )} + + {/* 申请记录 */} + +

+ + 申请记录 +

+ {isLoading ? ( +
加载中...
+ ) : records.length === 0 ? ( +
暂无离职申请记录
+ ) : ( +
+ {records.map((r: any) => { + const statusCfg = STATUS_MAP[r.status] || STATUS_MAP.DRAFT + const canWithdraw = r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL' + return ( +
+
+ {statusCfg.label} + + + {new Date(r.createdAt).toLocaleDateString('zh-CN')} + +
+
+
+ 预计离职日期 + {r.terminationDate ? new Date(r.terminationDate).toLocaleDateString('zh-CN') : '—'} +
+ {r.remark && ( +
{r.remark}
+ )} +
+ {canWithdraw && ( +
+ +
+ )} +
+ ) + })} +
+ )} +
+
+ ) +}