diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cbfc4da..bdf1b7b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -664,6 +664,7 @@ model Payslip { publishedAt DateTime? // 工资条发布到员工端的时间 publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED scheduledAt DateTime? // 定时发送时间 + viewedAt DateTime? // 员工查看工资条的时间 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index 16fff88..e99bd9b 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -18,6 +18,7 @@ import { getLeaveRecords, createLeaveRecord, deleteLeaveRecord, + manualCorrectAttendance, } from '../services/attendance.service' import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' @@ -206,6 +207,22 @@ router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest, // ========== 每日出勤 ========== +router.post('/manual-correct', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ + employeeId: z.string(), + date: z.string(), + checkInTime: z.string().optional(), + checkOutTime: z.string().optional(), + status: z.string().optional(), + remark: z.string().optional(), + }) + const data = schema.parse(req.body) + const record = await manualCorrectAttendance(req.user!.orgId, { ...data, createdBy: req.user!.id }) + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + router.get('/daily', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { const date = req.query.date as string diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index ef9d770..de7a919 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -79,7 +79,7 @@ router.get('/list', authMiddleware, async (req: AuthRequest, res, next) => { status: { in: status }, ...(department && { department }), }, - select: { id: true, name: true, department: true, position: true, phone: true, status: true }, + select: { id: true, name: true, department: true, position: true, phone: true, gender: true, status: true }, orderBy: { name: 'asc' }, }) res.json({ success: true, data: employees }) diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index 2c8ced0..977166f 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -115,7 +115,11 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => { if (!payslip) { return res.json({ success: true, data: null }) } - res.json({ success: true, data: payslip }) + // 记录查看时间 + if (!payslip.viewedAt) { + await prisma.payslip.update({ where: { id: payslip.id }, data: { viewedAt: new Date() } }) + } + res.json({ success: true, data: { ...payslip, viewedAt: payslip.viewedAt || new Date() } }) } catch (err) { next(err) } @@ -773,7 +777,7 @@ router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => { router.post('/resignation/submit', portalAuth, async (req: any, res, next) => { try { const { id: employeeId, orgId } = req.employee - const { reason, expectedDate, remark } = req.body + const { reason, expectedDate, remark, attachments } = req.body if (!reason || !expectedDate) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } }) } @@ -789,6 +793,7 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => { if (existing) { return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } }) } + const remarkText = `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}${attachments && attachments.length > 0 ? `;附件:${attachments.length}张辞职信照片` : ''}` const record = await (prisma as any).terminationRecord.create({ data: { employeeId, orgId, @@ -797,8 +802,8 @@ router.post('/resignation/submit', portalAuth, async (req: any, res, next) => { resignationReason: reason, terminationDate: new Date(expectedDate), status: 'PENDING_APPROVAL', - checklist: [], - remark: `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}`, + checklist: attachments && attachments.length > 0 ? attachments : [], + remark: remarkText, createdBy: employeeId, }, }) diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index a83376e..565dea8 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -95,6 +95,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { terminationDate: 'desc' }, take: 1 }, + socialInsRecords: { orderBy: { startMonth: 'desc' }, take: 1 }, _count: { select: { disciplinaryRecords: true, @@ -148,6 +149,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { id: e.id, name: e.name, department: e.department, + position: e.position, city: e.city, status: dynamicStatus, hasTermination: e.terminations.length > 0, @@ -168,6 +170,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { contractStatus: contractInfo.status, contractStatusText: contractInfo.statusText, riskLevel: contractInfo.riskLevel, + socialInsuranceStatus: (() => { + const sr = (e as any).socialInsRecords?.[0] + if (!sr) return null + // endMonth 为 null 表示在保,否则已停保 + if (sr.endMonth) return 'SUSPENDED' + return 'ACTIVE' + })(), probationInfo: (() => { if (!latestContract || latestContract.probationMonths === 0) return null const probEnd = new Date(e.hireDate) diff --git a/backend/src/services/attendance.service.ts b/backend/src/services/attendance.service.ts index 7da1817..56d423c 100644 --- a/backend/src/services/attendance.service.ts +++ b/backend/src/services/attendance.service.ts @@ -261,6 +261,60 @@ export async function deleteShiftAssignment(orgId: string, id: string) { // ========== 每日出勤 ========== +export async function manualCorrectAttendance(orgId: string, data: { + employeeId: string + date: string + checkInTime?: string + checkOutTime?: string + status?: string + remark?: string + createdBy?: string +}) { + const day = new Date(data.date) + day.setHours(0, 0, 0, 0) + const nextDay = new Date(day) + nextDay.setDate(nextDay.getDate() + 1) + + const existing = await prisma.attendanceRecord.findFirst({ + where: { orgId, employeeId: data.employeeId, date: { gte: day, lt: nextDay } }, + }) + + const checkInTime = data.checkInTime ? new Date(`${data.date}T${data.checkInTime}`).toISOString() : null + const checkOutTime = data.checkOutTime ? new Date(`${data.date}T${data.checkOutTime}`).toISOString() : null + + let workHours = 0 + if (checkInTime && checkOutTime) { + workHours = Math.round((new Date(checkOutTime).getTime() - new Date(checkInTime).getTime()) / 3600000 * 100) / 100 + } + + if (existing) { + return prisma.attendanceRecord.update({ + where: { id: existing.id }, + data: { + checkInTime, + checkOutTime, + status: data.status || 'NORMAL', + workHours, + remark: data.remark || existing.remark, + }, + }) + } else { + return prisma.attendanceRecord.create({ + data: { + orgId, + employeeId: data.employeeId, + date: day, + checkInTime, + checkOutTime, + status: data.status || 'NORMAL', + workHours, + remark: data.remark || null, + createdBy: data.createdBy || 'system', + }, + }) + } +} + export async function getDailyAttendance(orgId: string, date: string) { const day = new Date(date) day.setHours(0, 0, 0, 0) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35e5f9b..cbb5dc3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore' import TopNav from './components/layout/TopNav' import SidebarNav from './components/layout/SidebarNav' import MobileTabBar from './components/layout/MobileTabBar' +import OnboardingGuide from './components/OnboardingGuide' import PortalLayout from './components/layout/PortalLayout' import PageContainer from './components/layout/PageContainer' import { CommandPalette } from './components/ui/CommandPalette' @@ -87,6 +88,7 @@ function AdminLayout({ children }: { children: React.ReactNode }) { + ) } diff --git a/frontend/src/components/HelpModal.tsx b/frontend/src/components/HelpModal.tsx index 13aa927..a6ca2c5 100644 --- a/frontend/src/components/HelpModal.tsx +++ b/frontend/src/components/HelpModal.tsx @@ -1,9 +1,11 @@ import { useState, useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles, - Home, Users, FileText, Calculator, Bot, - Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react' + Home, Users, FileText, Calculator, Bot, Calendar, + Settings, Lightbulb, AlertTriangle, CheckCircle, Phone, RotateCcw, ShieldAlert, TrendingDown } from 'lucide-react' import Modal from './ui/Modal' import { aiApi } from '../lib/api-services' +import { resetOnboarding } from './OnboardingGuide' import clsx from 'clsx' interface HelpCategory { @@ -23,6 +25,41 @@ interface HelpArticle { } const categories: HelpCategory[] = [ + { + id: 'home', + title: '首页', + icon: Home, + articles: [ + { + id: 'system-intro', + question: '本系统能帮企业做什么?', + answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成', + }, + { + id: 'compliance', + question: '系统如何保障用工合规?', + answer: '系统从以下维度帮助企业实现合规管理:\n• 合同合规:自动提醒合同到期续签,检测未签合同风险(入职1个月内未签合同需支付双倍工资)\n• 薪酬合规:自动计算个税、社保扣款,确保发薪准确无误\n• 考勤合规:记录加班时长,预警超时加班风险,留存考勤证据\n• 解聘合规:自动计算经济补偿金,生成规范解聘协议,降低劳动争议风险\n• 社保合规:跟踪社保缴纳情况,提醒漏缴断缴\n• 风险预警:统一风险中心实时扫描所有数据,按高/中/低分级预警', + tip: '建议每周查看风险中心,每月核对薪税和考勤数据,确保合规无遗漏。', + }, + { + id: 'workflow', + question: '日常人事工作流程是怎样的?', + answer: '系统覆盖企业日常人事管理的完整流程:', + steps: [ + '入职:添加员工信息 → 签订合同 → 设置社保 → 安排排班', + '日常:考勤打卡 → 加班审批 → 休假管理 → 补卡修正', + '月度:导入考勤 → 确认考勤 → 计算工资 → 发放工资条 → 缴纳社保公积金 → 个税申报', + '合同:到期提醒 → 续签合同 → 合同确认', + '离职:发起解聘 → 计算补偿金 → 生成协议 → 完成离职', + ], + }, + { + id: 'value', + question: '使用系统能带来什么价值?', + answer: '• 提效:自动化算薪、考勤统计、合同管理,减少 80% 人工操作\n• 降险:法律风险自动检测预警,避免因疏忽导致的劳动纠纷和罚款\n• 省心:到期提醒、月度任务提醒,不再遗漏关键时间节点\n• 透明:员工可通过手机端查看工资条、合同、考勤记录,信息透明\n• 合规:所有操作留存记录,满足劳动法合规要求,应对审计无忧', + }, + ], + }, { id: 'start', title: '快速入门', @@ -146,21 +183,94 @@ const categories: HelpCategory[] = [ }, ], }, + { + id: 'attendance', + title: '考勤管理', + icon: Calendar, + articles: [ + { + id: 'attendance-overview', + question: '考勤管理有哪些功能?', + answer: '考勤管理包含 6 个子功能:\n• 考勤确认:导入考勤数据后批量确认并发布给员工\n• 班次管理:设置早班、晚班、弹性班等班次规则\n• 排班:按日期为员工分配班次,支持批量排班\n• 每日出勤:查看当日打卡情况,支持补卡修正\n• 月度报表:汇总月度出勤、迟到、加班数据\n• 休假记录:管理员工请假信息', + }, + { + id: 'shift-setup', + question: '怎么设置班次?', + answer: '在考勤管理「班次管理」标签页中,点击「新增班次」按钮,设置班次名称、上下班时间、弹性时长和休息时长。每个班次可以设置不同颜色方便区分。', + tip: '常见班次:早班 08:00-17:00、晚班 14:00-23:00、弹性班 09:00-18:00(弹性30分钟)。', + }, + { + id: 'schedule', + question: '怎么给员工排班?', + answer: '在考勤管理「排班」标签页中:', + steps: [ + '选择日期', + '在员工列表中,未排班的员工行内有班次下拉框', + '选择班次后点击「排班」按钮即可', + '也可以点击「批量排班」按钮,勾选多个员工一次性分配班次', + ], + tip: '支持按姓名或部门搜索,按部门筛选快速定位员工。', + }, + { + id: 'attendance-import', + question: '怎么导入考勤数据?', + answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。', + tip: '身份证号优先匹配,未填时用姓名匹配。', + }, + { + id: 'attendance-correct', + question: '员工漏打卡了怎么办?', + answer: '在「每日出勤」标签页中,找到对应员工,点击「补卡」按钮,手动填写签到/签退时间和状态即可修正记录。', + }, + ], + }, { id: 'risk', - title: '风险检测', - icon: AlertTriangle, + title: '风险中心', + icon: ShieldAlert, articles: [ { id: 'what-is-risk', - question: '风险检测是什么意思?', - answer: '系统会自动扫描您的员工和合同数据,发现可能存在的法律风险。比如:合同到期未续签、试用期超长、未签合同等。风险分为高、中、低三个等级,建议优先处理高风险项。', + question: '风险中心是什么?', + answer: '统一风险中心会自动扫描您的员工、合同、薪酬、社保等数据,汇总所有潜在风险。包括:合同到期未续签、未签合同、试用期超长、薪酬异常、社保漏缴、退休提醒等。风险分为高、中、低三个等级,建议优先处理高风险项。', + tip: '访问路径:左侧菜单「风险中心」或直接访问 /risk-center。', }, { id: 'how-to-fix', question: '发现风险后怎么处理?', - answer: '在首页「总览」页面可以看到风险概览。点击风险项可以跳转到对应员工详情,然后根据系统建议进行处理。处理完成后风险会自动消除。', - tip: '建议每周查看一次风险提醒,及时处理避免法律纠纷。', + answer: '在风险中心页面,每个风险项右侧有快捷操作按钮(如「续签」「转正」「处理」),点击即可跳转到对应页面处理。处理完成后风险会自动消除。', + tip: '建议每周查看一次风险中心,及时处理避免法律纠纷。', + }, + { + id: 'risk-types', + question: '有哪些类型的风险?', + answer: '系统目前检测以下风险类型:\n• 合同风险:到期未续签、未签合同\n• 薪酬风险:薪资异常波动\n• 解聘风险:可能存在劳动争议\n• 月度任务:发薪、社保、公积金、个税等截止日提醒\n• 入职手续:入职材料不完整\n• 退休提醒:员工即将达到退休年龄', + }, + ], + }, + { + id: 'termination', + title: '解聘管理', + icon: TrendingDown, + articles: [ + { + id: 'termination-process', + question: '员工离职怎么处理?', + answer: '在「解聘管理」页面处理离职流程:', + steps: [ + '点击「发起解聘」选择员工', + '填写解聘原因、离职日期等信息', + '系统自动计算经济补偿金', + '生成解聘协议书等法律文件', + '确认后完成解聘流程', + ], + warning: '不要直接删除员工记录,保留记录有助于日后查证和合规。', + }, + { + id: 'compensation', + question: '经济补偿金怎么算?', + answer: '系统根据员工工龄和月均工资自动计算经济补偿金:\n• 每满一年支付一个月工资\n• 六个月以上不满一年按一年算\n• 不满六个月支付半个月工资\n• 月工资按离职前12个月平均工资计算', + tip: '工资高于当地社平工资3倍的,按3倍封顶,最长补偿12年。', }, ], }, @@ -195,7 +305,12 @@ const categories: HelpCategory[] = [ { id: 'notification', question: '怎么设置提醒?', - answer: '在「通知管理」页面可以设置各类提醒:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 试用期到期提醒等\n点击通知铃铛图标可以查看所有未读提醒。', + answer: '在「设置」页面的「通知设置」标签中可以配置:\n• 合同到期提前提醒天数\n• 未签合同提醒\n• 加班超时提醒\n• 工资条发布通知\n• 月度事务提醒(发薪日、社保日、公积金日、个税日)\n• 企业微信 Webhook 推送\n• 邮件通知\n点击顶部通知铃铛图标可以查看所有未读提醒。', + }, + { + id: 'salary-dashboard', + question: '薪酬分析看板有什么用?', + answer: '薪酬分析看板在「薪税管理」页面中,提供:\n• 薪酬概览(员工总数、月均薪酬、中位数、年度总薪酬)\n• 部门薪酬对比(含人均薪酬排名)\n• 月度薪酬趋势(同比环比变化)\n帮助您了解薪酬分布情况,辅助预算决策。', }, { id: 'change-password', @@ -222,7 +337,7 @@ const categories: HelpCategory[] = [ { id: 'data-export', question: '可以导出数据吗?', - answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次也可以导出为 Excel 方便财务对账。', + answer: '可以。在员工管理页面可以导出员工名单为 Excel 文件。工资批次可以导出为 Excel 方便财务对账。考勤管理支持导出每日出勤和月度报表为 CSV 文件。', }, { id: 'multi-user', @@ -247,6 +362,7 @@ interface RAGResult { } export default function HelpModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const navigate = useNavigate() const [activeCategory, setActiveCategory] = useState(categories[0].id) const [expandedArticle, setExpandedArticle] = useState(null) const [searchQuery, setSearchQuery] = useState('') @@ -502,10 +618,19 @@ export default function HelpModal({ open, onClose }: { open: boolean; onClose: ( {/* 底部联系方式 */}
- - - 还有问题?在 AI 助手中直接提问 - +
+ + + 还有问题?在 AI 助手中直接提问 + + +
support@hr8ai.com
diff --git a/frontend/src/components/OnboardingGuide.tsx b/frontend/src/components/OnboardingGuide.tsx index f383b56..4c44062 100644 --- a/frontend/src/components/OnboardingGuide.tsx +++ b/frontend/src/components/OnboardingGuide.tsx @@ -1,80 +1,144 @@ import { useState, useEffect } from 'react' -import { X, ArrowRight } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { X, ArrowRight, Home, Users, Calculator, CalendarCheck, ShieldAlert, Bot } from 'lucide-react' -const STORAGE_KEY = 'hr-onboarding-completed' +const STORAGE_KEY = 'hr-onboarding-dismissed' -const steps = [ +const modules = [ { - icon: '🏠', - title: '这里看风险', - description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。', + icon: Home, + color: 'text-blue-600', + bg: 'bg-blue-50', + title: '工作台', + desc: '风险总览、待办事项、日历事件', + path: '/', }, { - icon: '�', - title: '这里管花名册', - description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。', + icon: Users, + color: 'text-indigo-600', + bg: 'bg-indigo-50', + title: '团队管理', + desc: '花名册、用工办理、离职管理、特殊状态', + path: '/roster', }, { - icon: '💰', - title: '这里算薪税', - description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。', + icon: Calculator, + color: 'text-amber-600', + bg: 'bg-amber-50', + title: '薪酬管理', + desc: '发薪批次、工资条、社保公积金、薪酬分析', + path: '/money', + }, + { + icon: CalendarCheck, + color: 'text-green-600', + bg: 'bg-green-50', + title: '考勤时间', + desc: '考勤打卡、排班管理、休假审批', + path: '/attendance', + }, + { + icon: ShieldAlert, + color: 'text-red-600', + bg: 'bg-red-50', + title: '合规风控', + desc: '风险中心、证据链、规章制度、用工体检', + path: '/risk-center', + }, + { + icon: Bot, + color: 'text-purple-600', + bg: 'bg-purple-50', + title: 'AI 助手', + desc: '智能咨询、合同审查、判赔预测、人力分析', + path: '/ai-assistant', }, ] +export function isOnboardingDismissed() { + return localStorage.getItem(STORAGE_KEY) === '1' +} + +export function dismissOnboarding() { + localStorage.setItem(STORAGE_KEY, '1') +} + +export function resetOnboarding() { + localStorage.removeItem(STORAGE_KEY) +} + export default function OnboardingGuide() { const [visible, setVisible] = useState(false) - const [step, setStep] = useState(0) + const [dontShow, setDontShow] = useState(false) + const navigate = useNavigate() useEffect(() => { - const completed = localStorage.getItem(STORAGE_KEY) - if (!completed) { + if (!isOnboardingDismissed()) { setVisible(true) } }, []) const close = () => { - localStorage.setItem(STORAGE_KEY, '1') + if (dontShow) dismissOnboarding() setVisible(false) } + const goTo = (path: string) => { + if (dontShow) dismissOnboarding() + setVisible(false) + navigate(path) + } + if (!visible) return null - const current = steps[step] - const isLast = step === steps.length - 1 - return (
-
-
+
+
+

欢迎使用企业用工专家

-
-
{current.icon}
-

{current.title}

-

{current.description}

- - {/* 进度指示器 */} -
- {steps.map((_, i) => ( -
- ))} +
+

系统包含以下 6 大模块,点击任意模块可直接前往体验:

+
+ {modules.map((m) => { + const Icon = m.icon + return ( + + ) + })}
-
- {step > 0 ? ( - - ) : } +
+
diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index e157406..570699f 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -5,6 +5,7 @@ import { Link, useLocation } from 'react-router-dom' import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' import clsx from 'clsx' import { LayoutDashboard, Users, CalendarCheck, UserX, @@ -16,6 +17,7 @@ import { Building2, CalendarDays, ClipboardList, Heart, CalendarClock, } from 'lucide-react' import Logo from '../ui/Logo' +import { settingsApi } from '../../lib/api-services' interface NavItem { path: string @@ -33,7 +35,7 @@ const navGroups: NavGroup[] = [ title: '首页', items: [ { path: '/', label: '工作台', icon: LayoutDashboard }, - { path: '/calendar', label: '日历', icon: CalendarDays }, + { path: '/calendar', label: '工作日历', icon: CalendarDays }, ], }, { @@ -49,7 +51,7 @@ const navGroups: NavGroup[] = [ title: '薪酬', items: [ { path: '/money', label: '薪税管理', icon: Calculator }, - { path: '/social', label: '社保公积金', icon: Shield }, + { path: '/social', label: '社公商保', icon: Shield }, { path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 }, ], }, @@ -64,7 +66,7 @@ const navGroups: NavGroup[] = [ title: '合规', items: [ { path: '/risk-center', label: '风险中心', icon: ShieldAlert }, - { path: '/evidence', label: '证据链', icon: FileSearch }, + { path: '/evidence', label: '证据链条', icon: FileSearch }, { path: '/policies', label: '规章制度', icon: FileText }, { path: '/tools/health-check', label: '用工体检', icon: Stethoscope }, { path: '/tools/medical-period', label: '医疗期', icon: HeartPulse }, @@ -79,7 +81,7 @@ const navGroups: NavGroup[] = [ { path: '/notifications', label: '通知管理', icon: Bell }, { path: '/audit', label: '操作日志', icon: ScrollText }, { path: '/company-files', label: '公司文件', icon: Building2 }, - { path: '/settings', label: '设置', icon: Settings }, + { path: '/settings', label: '系统设置', icon: Settings }, ], }, ] @@ -89,13 +91,18 @@ const navGroups: NavGroup[] = [ */ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) { const location = useLocation() + const { data: orgData } = useQuery({ + queryKey: ['org-settings'], + queryFn: () => settingsApi.org(), + staleTime: 300000, + }) const isActive = (path: string) => { if (path === '/') return location.pathname === '/' return location.pathname.startsWith(path) } const activeGroup = navGroups.find(g => g.items.some(item => isActive(item.path))) const [expandedGroups, setExpandedGroups] = useState>( - new Set(activeGroup ? [activeGroup.title] : ['首页']) + new Set(navGroups.map(g => g.title)) ) const toggleGroup = (title: string) => { @@ -131,7 +138,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea {/* Logo 区 */}
- 企业用工专家 + {orgData?.name || '企业用工专家'}
{/* 导航菜单 */} diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index b643047..63deeb9 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -3,7 +3,7 @@ import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartpho import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/authStore' -import { dashboardApi, settingsApi } from '../../lib/api-services' +import { dashboardApi } from '../../lib/api-services' import Breadcrumb from './Breadcrumb' import HelpModal from '../HelpModal' import PortalQRModal from '../PortalQRModal' @@ -22,11 +22,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { queryFn: () => dashboardApi.data(), refetchInterval: 60000, }) - const { data: orgData } = useQuery({ - queryKey: ['org-settings'], - queryFn: () => settingsApi.org(), - staleTime: 300000, - }) const riskCount = dashboardData?.riskSummary?.pending || 0 return ( @@ -41,12 +36,6 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { > - {orgData?.name && ( - - {orgData.name} - - )} - {orgData?.name && |}
diff --git a/frontend/src/hooks/usePageSize.ts b/frontend/src/hooks/usePageSize.ts new file mode 100644 index 0000000..fe698e4 --- /dev/null +++ b/frontend/src/hooks/usePageSize.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react' +import { getPageSize } from '../lib/pageSize' + +/** + * 响应式分页大小 hook + * 当用户在系统设置中修改分页大小时,所有使用此 hook 的页面会自动更新 + */ +export function usePageSize() { + const [pageSize, setPageSizeState] = useState(getPageSize()) + + useEffect(() => { + const handler = () => setPageSizeState(getPageSize()) + window.addEventListener('page-size-changed', handler) + return () => window.removeEventListener('page-size-changed', handler) + }, []) + + return pageSize +} diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index f09234f..f7d4b57 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -282,6 +282,9 @@ export const attendanceApi = { /** 删除请假记录 */ removeLeave: (id: string) => del(`/attendance/leaves/${id}`), + /** 手动补卡/修正考勤 */ + manualCorrect: (data: { employeeId: string; date: string; checkInTime?: string; checkOutTime?: string; status?: string; remark?: string }) => + post('/attendance/manual-correct', data).then(unwrap()), } // ========== 休假审批流 ========== diff --git a/frontend/src/lib/pageSize.ts b/frontend/src/lib/pageSize.ts new file mode 100644 index 0000000..fbe151d --- /dev/null +++ b/frontend/src/lib/pageSize.ts @@ -0,0 +1,21 @@ +/** + * 全局分页大小管理 + * 默认 10 条/页,用户可在系统设置中修改,存储在 localStorage + */ + +const STORAGE_KEY = 'hr-page-size' + +export const DEFAULT_PAGE_SIZE = 10 + +/** 获取当前分页大小 */ +export function getPageSize(): number { + const val = localStorage.getItem(STORAGE_KEY) + const n = val ? parseInt(val, 10) : NaN + return Number.isFinite(n) && n > 0 ? n : DEFAULT_PAGE_SIZE +} + +/** 设置分页大小 */ +export function setPageSize(size: number): void { + localStorage.setItem(STORAGE_KEY, String(size)) + window.dispatchEvent(new CustomEvent('page-size-changed')) +} diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx index 407f8ac..d7dacf3 100644 --- a/frontend/src/pages/Attendance.tsx +++ b/frontend/src/pages/Attendance.tsx @@ -1,13 +1,16 @@ import { useState, useRef } from 'react' +import { Link } from 'react-router-dom' 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, CheckCheck } from 'lucide-react' +import { CalendarCheck, CheckCircle, Clock, AlertCircle, Plus, Trash2, Calendar, Users, BarChart3, Plane, Upload, Download, X, Send, Loader2, CheckCheck, Edit } from 'lucide-react' import { attendanceApi, employeeApi, rosterApi } from '../lib/api-services' import { useAuthStore } from '../store/authStore' +import { usePageSize } from '../hooks/usePageSize' import Card from '../components/ui/Card' import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' +import Pagination from '../components/ui/Pagination' import EmptyState from '../components/ui/EmptyState' import { InlineAlert } from '../components/ui/InlineAlert' import PageGuide from '../components/ui/PageGuide' @@ -102,7 +105,12 @@ function ConfirmTab() { const [importResult, setImportResult] = useState(null) const [importing, setImporting] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) + const [searchQuery, setSearchQuery] = useState('') + const [editItem, setEditItem] = useState(null) + const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 }) const fileInputRef = useRef(null) + const pageSize = usePageSize() + const [page, setPage] = useState(1) const { data: list, isLoading } = useQuery({ queryKey: ['attendance', month, filterDepartment, filterStatus], @@ -180,10 +188,33 @@ function ConfirmTab() { onError: (err: any) => toast.error(err?.response?.data?.error?.message || '确认失败'), }) + const editMutation = useMutation({ + mutationFn: async (data: any) => { + return await attendanceApi.manualCorrect(data) + }, + onSuccess: () => { + toast.success('考勤记录已修改') + queryClient.invalidateQueries({ queryKey: ['attendance'] }) + queryClient.invalidateQueries({ queryKey: ['attendance-stats'] }) + setEditItem(null) + }, + 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 allList = list || [] + const filteredList = allList.filter((i: any) => { + if (searchQuery.trim()) { + const q = searchQuery.trim().toLowerCase() + if (!i.employee?.name?.toLowerCase().includes(q) && !i.employee?.department?.toLowerCase().includes(q)) return false + } + return true + }) + const pendingItems = filteredList.filter((i: any) => i.status === 'PENDING') const allPendingSelected = pendingItems.length > 0 && pendingItems.every((i: any) => selectedIds.has(i.id)) + const total = filteredList.length + const pagedList = filteredList.slice((page - 1) * pageSize, page * pageSize) const toggleSelect = (id: string) => { const next = new Set(selectedIds) @@ -300,6 +331,13 @@ function ConfirmTab() { + { setSearchQuery(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44" + /> setEditForm({ ...editForm, workDays: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, lateCount: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, earlyLeaveCount: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, absentDays: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, leaveDays: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, overtimeHours: Number(e.target.value) })} /> +
+
+ + setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} /> +
+
+
+ + +
+
+ +
+ )} + setPage(1)} /> + )} {/* 导入考勤弹窗 */} @@ -629,6 +744,10 @@ function ScheduleTab() { const [selectedShiftId, setSelectedShiftId] = useState('') const [selectedEmployeeIds, setSelectedEmployeeIds] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState('') + const [filterDept, setFilterDept] = useState('') + const pageSize = usePageSize() + const [page, setPage] = useState(1) + const [inlineShiftId, setInlineShiftId] = useState>({}) const { data: shifts } = useQuery({ queryKey: ['shifts'], @@ -678,9 +797,20 @@ function ScheduleTab() { batchAssignMutation.mutate(items) } - const employees = dailyData || [] + const allEmployees = dailyData || [] const assignmentMap: Map = new Map((assignments || []).map((a: any) => [a.employeeId, a])) + const filteredEmployees = allEmployees.filter((emp: any) => { + if (filterDept && emp.department !== filterDept) return false + if (searchQuery.trim()) { + const q = searchQuery.trim().toLowerCase() + if (!emp.name?.toLowerCase().includes(q) && !emp.department?.toLowerCase().includes(q)) return false + } + return true + }) + const total = filteredEmployees.length + const employees = filteredEmployees.slice((page - 1) * pageSize, page * pageSize) + const toggleEmployee = (id: string) => { const next = new Set(selectedEmployeeIds) if (next.has(id)) next.delete(id) @@ -688,18 +818,43 @@ function ScheduleTab() { setSelectedEmployeeIds(next) } + const handleInlineAssign = (employeeId: string) => { + const shiftId = inlineShiftId[employeeId] + if (!shiftId) return toast.error('请先选择班次') + batchAssignMutation.mutate([{ employeeId, shiftId, date }]) + } + return (
排班用于按日期为员工分配班次。选择日期后可查看当日排班情况,点击「排班」按钮为员工分配班次。支持批量排班和复制排班。 -
- setDate(e.target.value)} - className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> +
+
+ { setDate(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> + { setSearchQuery(e.target.value); setPage(1) }} + className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary w-44" + /> + +
@@ -707,9 +862,10 @@ function ScheduleTab() { {isLoading ? (
加载中...
- ) : employees.length === 0 ? ( + ) : total === 0 ? ( ) : ( + <> @@ -717,7 +873,7 @@ function ScheduleTab() { - + @@ -726,7 +882,7 @@ function ScheduleTab() { return ( - + - ) @@ -748,6 +923,8 @@ function ScheduleTab() {
姓名 部门 班次操作操作
{emp.name}{emp.department}{emp.department || '未分配'} {assignment ? ( @@ -737,10 +893,29 @@ function ScheduleTab() { 未排班 )} - {assignment && ( - - )} + +
+ {assignment ? ( + + ) : ( + <> + + + + )} +
+ setPage(1)} /> + )} setShowAssign(false)} title="批量排班"> @@ -771,11 +948,7 @@ function ScheduleTab() { className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" />
- {employees.filter((emp: any) => { - if (!searchQuery.trim()) return true - const q = searchQuery.trim().toLowerCase() - return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q) - }).map((emp: any) => ( + {filteredEmployees.map((emp: any) => (