feat: 完成23项系统优化 - 花名册社保状态列/身份证复制/附件类型扩展, 用工办理姓名检索/直接提交/文书查看/批量证明, 风险中心跳转筛选+批量处理, 日历7/15/35天分组+逾期统计, 考勤单条编辑+按人导出, 工资条查看状态+工资流水导出, 辞职申请附件上传, 交接清单PDF下载, 操作完成下一步引导, 休假审批入口, 人效成本分部门

This commit is contained in:
freedakgmail
2026-08-04 22:55:03 +08:00
parent 338af5eee9
commit 5604d02de9
41 changed files with 2104 additions and 482 deletions
+1
View File
@@ -664,6 +664,7 @@ model Payslip {
publishedAt DateTime? // 工资条发布到员工端的时间
publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED
scheduledAt DateTime? // 定时发送时间
viewedAt DateTime? // 员工查看工资条的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+17
View File
@@ -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
+1 -1
View File
@@ -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 })
+9 -4
View File
@@ -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,
},
})
+9
View File
@@ -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)
@@ -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)
+2
View File
@@ -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 }) {
</main>
<MobileTabBar />
</div>
<OnboardingGuide />
</div>
)
}
+135 -10
View File
@@ -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<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
@@ -502,10 +618,19 @@ export default function HelpModal({ open, onClose }: { open: boolean; onClose: (
{/* 底部联系方式 */}
<div className="border-t border-gray-200 px-4 py-2.5 flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5">
<HelpCircle className="w-3.5 h-3.5" />
AI
</span>
<button
onClick={() => { resetOnboarding(); onClose(); navigate('/'); setTimeout(() => window.location.reload(), 100) }}
className="flex items-center gap-1 text-primary hover:underline"
>
<RotateCcw className="w-3 h-3" />
</button>
</div>
<span>support@hr8ai.com</span>
</div>
</Modal>
+106 -42
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
<h2 className="text-base font-semibold">使</h2>
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
<div className="px-5 py-4">
<p className="text-sm text-gray-500 mb-4"> 6 </p>
<div className="grid grid-cols-2 gap-3">
{modules.map((m) => {
const Icon = m.icon
return (
<button
key={m.title}
onClick={() => goTo(m.path)}
className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/[0.02] transition-all text-left"
>
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg} ${m.color} shrink-0`}>
<Icon className="w-4.5 h-4.5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800">{m.title}</div>
<div className="text-xs text-gray-500 mt-0.5 leading-relaxed">{m.desc}</div>
</div>
</button>
)
})}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<div className="mt-4 flex items-center justify-between">
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
<input
type="checkbox"
checked={dontShow}
onChange={(e) => setDontShow(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/10"
/>
</label>
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
onClick={close}
className="flex items-center gap-1 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
使
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
+13 -6
View File
@@ -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<any>({
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<Set<string>>(
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 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
<Logo className="w-5 h-5 text-primary" />
<span className="font-bold text-sm text-gray-900"></span>
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
</div>
{/* 导航菜单 */}
+1 -12
View File
@@ -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<any>({
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 }) {
>
<Menu className="w-5 h-5 text-gray-600" />
</button>
{orgData?.name && (
<span className="hidden sm:inline text-sm font-medium text-gray-700 shrink-0">
{orgData.name}
</span>
)}
{orgData?.name && <span className="hidden sm:inline text-gray-300 shrink-0">|</span>}
<Breadcrumb />
</div>
+18
View File
@@ -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
}
+3
View File
@@ -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<any>()),
}
// ========== 休假审批流 ==========
+21
View File
@@ -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'))
}
+428 -26
View File
@@ -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<any>(null)
const [importing, setImporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editItem, setEditItem] = useState<any>(null)
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: list, isLoading } = useQuery<any>({
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() {
<Button size="sm" variant="secondary" onClick={() => setShowImport(true)}>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { 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"
/>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
@@ -345,11 +383,12 @@ function ConfirmTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !list || list.length === 0 ? (
) : total === 0 ? (
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
) : (
<>
<div className="space-y-2">
{list.map((item: any) => {
{pagedList.map((item: any) => {
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
const StatusIcon = config.icon
const isSelected = selectedIds.has(item.id)
@@ -387,6 +426,7 @@ function ConfirmTab() {
</div>
<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)}
@@ -394,6 +434,24 @@ function ConfirmTab() {
>
</button>
<button
className="text-xs text-gray-500 hover:text-primary"
onClick={() => {
setEditItem(item)
setEditForm({
workDays: item.workDays || 0,
lateCount: item.lateCount || 0,
earlyLeaveCount: item.earlyLeaveCount || 0,
absentDays: item.absentDays || 0,
leaveDays: item.leaveDays || 0,
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
overtimePay: item.overtimePay || 0,
})
}}
>
</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" />
@@ -405,6 +463,63 @@ function ConfirmTab() {
)
})}
</div>
{editItem && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setEditItem(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {editItem.employee?.name}</h2>
<button onClick={() => setEditItem(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.workDays}
onChange={(e) => setEditForm({ ...editForm, workDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.lateCount}
onChange={(e) => setEditForm({ ...editForm, lateCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500">退</label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.earlyLeaveCount}
onChange={(e) => setEditForm({ ...editForm, earlyLeaveCount: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.absentDays}
onChange={(e) => setEditForm({ ...editForm, absentDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.leaveDays}
onChange={(e) => setEditForm({ ...editForm, leaveDays: Number(e.target.value) })} />
</div>
<div>
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.5" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimeHours}
onChange={(e) => setEditForm({ ...editForm, overtimeHours: Number(e.target.value) })} />
</div>
<div className="col-span-2">
<label className="text-xs text-gray-500"></label>
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}></Button>
<Button size="sm" onClick={() => editMutation.mutate({ employeeId: editItem.employeeId, month, ...editForm })} disabled={editMutation.isPending}>
{editMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Card>
</div>
)}
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 导入考勤弹窗 */}
@@ -629,6 +744,10 @@ function ScheduleTab() {
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [inlineShiftId, setInlineShiftId] = useState<Record<string, string>>({})
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
@@ -678,9 +797,20 @@ function ScheduleTab() {
batchAssignMutation.mutate(items)
}
const employees = dailyData || []
const allEmployees = dailyData || []
const assignmentMap: Map<string, any> = 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 (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
onChange={e => { 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"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { 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"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allEmployees.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button onClick={() => setShowAssign(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -707,9 +862,10 @@ function ScheduleTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : employees.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有可排班的员工" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -717,7 +873,7 @@ function ScheduleTab() {
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center w-48"></th>
</tr>
</thead>
<tbody>
@@ -726,7 +882,7 @@ function ScheduleTab() {
return (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
<td className="px-4 py-3 text-gray-500">{emp.department || '未分配'}</td>
<td className="px-4 py-3">
{assignment ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs" style={{ background: (assignment.shift as any)?.color + '20', color: (assignment.shift as any)?.color }}>
@@ -737,10 +893,29 @@ function ScheduleTab() {
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{assignment && (
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
{assignment ? (
<button className="text-xs text-gray-400 hover:text-red-500" onClick={() => deleteAssignmentMutation.mutate(assignment.id)}></button>
) : (
<>
<select
value={inlineShiftId[emp.employeeId] || ''}
onChange={e => setInlineShiftId(prev => ({ ...prev, [emp.employeeId]: e.target.value }))}
className="h-7 rounded border border-gray-200 text-xs px-1 max-w-[100px]"
>
<option value=""></option>
{(shifts || []).map((s: any) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
className="text-xs text-primary hover:underline whitespace-nowrap"
onClick={() => handleInlineAssign(emp.employeeId)}
></button>
</>
)}
</div>
</td>
</tr>
)
@@ -748,6 +923,8 @@ function ScheduleTab() {
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAssign} onClose={() => 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"
/>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{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) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
@@ -796,7 +969,14 @@ function ScheduleTab() {
// ========== 每日出勤 Tab ==========
function DailyTab() {
const queryClient = useQueryClient()
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [editEmp, setEditEmp] = useState<any>(null)
const [editForm, setEditForm] = useState({ checkInTime: '', checkOutTime: '', status: 'NORMAL', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['daily-attendance', date],
@@ -805,6 +985,16 @@ function DailyTab() {
},
})
const correctMutation = useMutation({
mutationFn: (data: any) => attendanceApi.manualCorrect(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['daily-attendance'] })
toast.success('考勤记录已修正')
setEditEmp(null)
},
onError: () => toast.error('修正失败'),
})
const statusColors: Record<string, string> = {
NORMAL: 'bg-green-50 text-green-700',
LATE: 'bg-amber-50 text-amber-700',
@@ -815,25 +1005,75 @@ function DailyTab() {
UNREGISTERED: 'bg-gray-100 text-gray-500',
}
const allData = data || []
const filteredData = allData.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 = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
//退/
</PageGuide>
<div className="flex justify-end">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { 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"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" size="sm" onClick={() => {
if (!data || data.length === 0) return
const headers = ['姓名', '部门', '班次', '签到', '签退', '状态', '工时']
const rows = data.map((emp: any) => [
emp.name, emp.department, emp.shift?.name || '', emp.checkInTime || '', emp.checkOutTime || '',
ATTENDANCE_STATUS[emp.status] || emp.status, emp.workHours > 0 ? `${emp.workHours}h` : '0',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤-${date}.csv`
a.click()
URL.revokeObjectURL(url)
}} disabled={!data || data.length === 0}>
<Download className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无员工" description="没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -845,10 +1085,11 @@ function DailyTab() {
<th className="px-4 py-3 text-left">退</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((emp: any) => (
{pagedData.map((emp: any) => (
<tr key={emp.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{emp.name}</td>
<td className="px-4 py-3 text-gray-500">{emp.department}</td>
@@ -861,11 +1102,72 @@ function DailyTab() {
</span>
</td>
<td className="px-4 py-3 text-right text-xs">{emp.workHours > 0 ? `${emp.workHours}h` : '—'}</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setEditEmp(emp)
setEditForm({
checkInTime: emp.checkInTime || '',
checkOutTime: emp.checkOutTime || '',
status: emp.status || 'NORMAL',
remark: '',
})
}}
>
<Edit className="w-3.5 h-3.5 inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
{/* 补卡弹窗 */}
{editEmp && (
<Modal open onClose={() => setEditEmp(null)}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"> - {editEmp.name}</h3>
<button onClick={() => setEditEmp(null)} className="text-gray-500 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="text-xs text-gray-500">{date}</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="time" value={editForm.checkInTime} onChange={(e) => setEditForm({ ...editForm, checkInTime: e.target.value })} />
</div>
<div>
<Label>退</Label>
<Input type="time" value={editForm.checkOutTime} onChange={(e) => setEditForm({ ...editForm, checkOutTime: e.target.value })} />
</div>
<div className="col-span-2">
<Label></Label>
<Select value={editForm.status} onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}>
<option value="NORMAL"></option>
<option value="LATE"></option>
<option value="EARLY_LEAVE">退</option>
<option value="ABSENT"></option>
<option value="LEAVE"></option>
<option value="BUSINESS_TRIP"></option>
</Select>
</div>
<div className="col-span-2">
<Label></Label>
<Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} placeholder="补卡原因/备注" />
</div>
</div>
<Button onClick={() => correctMutation.mutate({ employeeId: editEmp.employeeId, date, ...editForm })} disabled={correctMutation.isPending} className="w-full">
{correctMutation.isPending ? '提交中...' : '确认修正'}
</Button>
</div>
</Modal>
)}
</div>
)
@@ -874,6 +1176,10 @@ function DailyTab() {
// ========== 月度报表 Tab ==========
function MonthlyTab() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [searchQuery, setSearchQuery] = useState('')
const [filterDept, setFilterDept] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery<any>({
queryKey: ['monthly-report', month],
@@ -899,18 +1205,75 @@ function MonthlyTab() {
URL.revokeObjectURL(url)
}
const handleExportSingle = (r: any) => {
const headers = ['项目', '数值']
const rows = [
['姓名', r.name],
['部门', r.department],
['月份', month],
['出勤天数', r.workDays],
['迟到次数', r.lateCount],
['早退次数', r.earlyLeaveCount],
['缺勤天数', r.absentDays],
['请假天数', r.leaveDays],
['加班工时', r.overtimeHours?.toFixed(1) || '0'],
['加班费', `¥${r.overtimePay?.toFixed(2) || '0.00'}`],
['确认状态', r.confirmationStatus === 'CONFIRMED' ? '已确认' : r.confirmationStatus === 'PENDING' ? '待确认' : r.confirmationStatus === 'DISPUTED' ? '有异议' : '未创建'],
]
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `考勤明细-${r.name}-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success(`已导出 ${r.name}${month} 月考勤明细`)
}
const allData = data || []
const filteredData = allData.filter((r: any) => {
if (filterDept && r.department !== filterDept) return false
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!r.name?.toLowerCase().includes(q) && !r.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredData.length
const pagedData = filteredData.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<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"
/>
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { 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"
/>
<select
value={filterDept}
onChange={e => { setFilterDept(e.target.value); setPage(1) }}
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm"
>
<option value=""></option>
{Array.from(new Set(allData.map((e: any) => e.department).filter(Boolean) as string[])).map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleExport} disabled={!data || data.length === 0}>
<BarChart3 className="w-4 h-4 mr-1" /> CSV
</Button>
@@ -918,9 +1281,10 @@ function MonthlyTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !data || data.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无报表数据" description="该月份没有出勤数据" />
) : (
<>
<Card className="overflow-hidden p-0">
<table className="w-full text-sm">
<thead className="bg-gray-50/90">
@@ -935,10 +1299,11 @@ function MonthlyTab() {
<th className="px-4 py-3 text-center">(h)</th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody>
{data.map((r: any) => (
{pagedData.map((r: any) => (
<tr key={r.employeeId} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-500">{r.department}</td>
@@ -955,11 +1320,21 @@ function MonthlyTab() {
: r.confirmationStatus === 'DISPUTED' ? <span className="text-xs text-red-600"></span>
: <span className="text-xs text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-center">
<button
className="text-xs text-primary hover:underline"
onClick={() => handleExportSingle(r)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</div>
)
@@ -971,6 +1346,9 @@ function LeavesTab() {
const confirm = useConfirm()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
const [searchQuery, setSearchQuery] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: leaves, isLoading } = useQuery<any>({
queryKey: ['leave-records'],
@@ -1006,13 +1384,34 @@ function LeavesTab() {
}
const employees = rosterData || []
const allLeaves = leaves || []
const filteredLeaves = allLeaves.filter((lv: any) => {
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase()
if (!lv.employee?.name?.toLowerCase().includes(q) && !lv.employee?.department?.toLowerCase().includes(q)) return false
}
return true
})
const total = filteredLeaves.length
const pagedLeaves = filteredLeaves.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end">
<div className="flex justify-end items-center gap-3">
<input
type="text"
placeholder="搜索姓名或部门"
value={searchQuery}
onChange={e => { 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"
/>
<Link to="/leave-approval" className="text-xs text-primary hover:underline flex items-center gap-1">
<Plane className="w-3.5 h-3.5" />
</Link>
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
@@ -1020,11 +1419,12 @@ function LeavesTab() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !leaves || leaves.length === 0 ? (
) : total === 0 ? (
<EmptyState title="暂无休假记录" description="点击右上角添加休假记录" />
) : (
<>
<div className="space-y-2">
{leaves.map((lv: any) => (
{pagedLeaves.map((lv: any) => (
<Card key={lv.id}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -1050,6 +1450,8 @@ function LeavesTab() {
</Card>
))}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
<Modal open={showAdd} onClose={() => setShowAdd(false)} title="新增休假记录">
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ScrollText } from 'lucide-react'
import { auditApi } from '../lib/api-services'
@@ -118,8 +119,8 @@ function formatDetail(detail: any): string {
* 系统操作日志页面
*/
export default function AuditLog() {
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [action, setAction] = useState('')
const [entity, setEntity] = useState('')
const [dateFrom, setDateFrom] = useState('')
@@ -236,7 +237,7 @@ export default function AuditLog() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+113 -10
View File
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, AlertCircle, Clock, Bell } from 'lucide-react'
import { dashboardApi, calendarApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -119,6 +119,34 @@ export default function Calendar() {
return events
}, [calendarData, typeFilter])
const todayStr = fmtDate(new Date())
const eventStats = useMemo(() => {
const overdue: any[] = []
const urgent: any[] = []
const warning: any[] = []
const remind: any[] = []
for (const ev of allEvents) {
const diff = Math.floor((new Date(ev.date).getTime() - new Date(todayStr).getTime()) / 86400000)
if (diff < 0) overdue.push({ ...ev, overdueDays: -diff })
else if (diff <= 7) urgent.push(ev)
else if (diff <= 15) warning.push(ev)
else if (diff <= 35) remind.push(ev)
}
const totalOverdueDays = overdue.reduce((s, e) => s + e.overdueDays, 0)
return { overdue, urgent, warning, remind, totalOverdueDays }
}, [allEvents, todayStr])
const groupedEvents = useMemo(() => {
const groups = [
{ key: 'overdue', label: '已逾期', color: 'text-red-600', bg: 'bg-red-50', icon: AlertCircle, items: eventStats.overdue },
{ key: 'urgent', label: '7天内紧急', color: 'text-orange-600', bg: 'bg-orange-50', icon: AlertCircle, items: eventStats.urgent },
{ key: 'warning', label: '15天预警', color: 'text-amber-600', bg: 'bg-amber-50', icon: Clock, items: eventStats.warning },
{ key: 'remind', label: '35天提醒', color: 'text-blue-600', bg: 'bg-blue-50', icon: Bell, items: eventStats.remind },
]
return groups.filter(g => g.items.length > 0)
}, [eventStats])
const customEventMap = useMemo(() => {
const map: Record<string, any> = {}
for (const ev of (customEvents || [])) {
@@ -188,6 +216,38 @@ export default function Calendar() {
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-50 text-red-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-red-600">{eventStats.overdue.length}</div>
<div className="text-xs text-gray-500">{eventStats.totalOverdueDays}</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-orange-50 text-orange-600"><AlertCircle className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-orange-600">{eventStats.urgent.length}</div>
<div className="text-xs text-gray-500">7</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-50 text-amber-600"><Clock className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-amber-600">{eventStats.warning.length}</div>
<div className="text-xs text-gray-500">15</div>
</div>
</Card>
<Card className="flex items-center gap-2.5 py-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-50 text-blue-600"><Bell className="w-4 h-4" /></div>
<div>
<div className="text-base font-bold text-blue-600">{eventStats.remind.length}</div>
<div className="text-xs text-gray-500">35</div>
</div>
</Card>
</div>
{/* 类型筛选 */}
<div className="flex items-center gap-2 flex-wrap">
<button
@@ -235,16 +295,36 @@ export default function Calendar() {
{cell.day}
</div>
<div className="space-y-0.5">
{cell.events.slice(0, 3).map((ev: any, idx: number) => (
{cell.events.slice(0, 3).map((ev: any, idx: number) => {
const content = (
<>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
</>
)
if (ev.actionUrl && ev.actionUrl !== '/dashboard') {
return (
<Link
key={idx}
to={ev.actionUrl}
className={`block text-[10px] leading-tight px-1 py-0.5 rounded truncate hover:underline ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
onClick={(e) => e.stopPropagation()}
>
{content}
</Link>
)
}
return (
<div
key={idx}
className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}
title={ev.title}
>
<span className={`inline-block w-1 h-1 rounded-full mr-0.5 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
{ev.title}
{content}
</div>
))}
)
})}
{cell.events.length > 3 && (
<div className="text-[10px] text-gray-400 px-1">+{cell.events.length - 3} </div>
)}
@@ -266,9 +346,19 @@ export default function Calendar() {
<span className="text-xs text-gray-400 font-normal">({allEvents.length})</span>
</h3>
{allEvents.length > 0 ? (
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
{allEvents.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-2 rounded-md hover:bg-gray-50 group">
<div className="space-y-3 max-h-[500px] overflow-y-auto">
{groupedEvents.length > 0 ? groupedEvents.map(group => {
const GIcon = group.icon
return (
<div key={group.key}>
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-md ${group.bg} mb-1 sticky top-0`}>
<GIcon className={`w-3.5 h-3.5 ${group.color}`} />
<span className={`text-xs font-medium ${group.color}`}>{group.label}</span>
<span className="text-xs text-gray-400">({group.items.length})</span>
</div>
<div className="space-y-1">
{group.items.map((ev: any, i: number) => (
<div key={i} className="flex items-start gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 group">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${PRIORITY_DOT[ev.priority] || 'bg-gray-400'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
@@ -276,16 +366,23 @@ export default function Calendar() {
<span className={`text-[10px] px-1 py-0.5 rounded ${EVENT_TYPE_COLORS[ev.type] || 'bg-gray-100 text-gray-600'}`}>
{EVENT_TYPE_LABELS[ev.type] || ev.type}
</span>
{group.key === 'overdue' && (
<span className="text-[10px] text-red-600 font-medium">{ev.overdueDays}</span>
)}
</div>
<div className="text-xs text-gray-800 mt-0.5 truncate">
{ev.title}
{ev.employeeName && <span className="text-gray-400 ml-1"> {ev.employeeName}</span>}
</div>
{ev.actionUrl && ev.actionUrl !== '/dashboard' && (
{ev.type === 'CONTRACT_EXPIRY' && ev.employeeName ? (
<Link to={`/roster?employee=${encodeURIComponent(ev.employeeName)}`} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
) : ev.actionUrl && ev.actionUrl !== '/dashboard' ? (
<Link to={ev.actionUrl} className="text-[10px] text-primary hover:underline mt-0.5 inline-block">
</Link>
)}
) : null}
</div>
{isCustomEvent(ev) && customEventMap[ev.id] && (
<button
@@ -298,6 +395,12 @@ export default function Calendar() {
</div>
))}
</div>
</div>
)
}) : (
<div className="text-xs text-gray-500 text-center py-8">35</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-8"></div>
)}
+1 -1
View File
@@ -118,7 +118,7 @@ export default function CompanyFiles() {
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32 text-xs">
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="!w-28 text-xs">
<option value=""></option>
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
@@ -37,8 +38,8 @@ export default function Contracts() {
const [search, setSearch] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
@@ -185,7 +186,7 @@ export default function Contracts() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
@@ -39,8 +40,8 @@ function TodoIcon({ type }: { type: string; level: string }) {
}
export default function Dashboard() {
const todoPageSize = usePageSize()
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'risk' | 'task' | 'cost' | 'workforce'>('overview')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -1128,7 +1129,6 @@ export default function Dashboard() {
</>
)}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
@@ -1196,6 +1196,7 @@ export default function Dashboard() {
</div>
))}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
</>
)}
</Card>
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery } from '@tanstack/react-query'
import { ShieldCheck, FileText, CheckCircle, XCircle } from 'lucide-react'
import { evidenceApi } from '../lib/api-services'
@@ -13,8 +14,8 @@ import QueryError from '../components/ui/QueryError'
*/
export default function Evidence() {
const [refType, setRefType] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['evidence', refType, page, pageSize],
@@ -128,7 +129,7 @@ export default function Evidence() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</div>
)
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
@@ -30,8 +31,8 @@ const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
@@ -143,7 +144,6 @@ export default function LeaveApproval() {
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -220,6 +220,7 @@ export default function LeaveApproval() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react'
@@ -32,8 +33,8 @@ const CHANNEL_LABELS: Record<string, string> = {
*/
export default function Notifications() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showSettings, setShowSettings] = useState(false)
const { data, isLoading } = useQuery<any>({
@@ -141,7 +142,7 @@ export default function Notifications() {
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</>
)}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
@@ -26,8 +27,8 @@ export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['policies', page, pageSize],
@@ -130,7 +131,7 @@ export default function Policies() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 详情弹窗 */}
+150 -51
View File
@@ -1,9 +1,10 @@
import { useState, useMemo, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download } from 'lucide-react'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -20,10 +21,45 @@ import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChan
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
const ROSTER_COLUMNS = [
{ key: 'department', label: '部门' },
{ key: 'status', label: '状态' },
{ key: 'hireDate', label: '入职日期' },
{ key: 'position', label: '职务' },
{ key: 'phone', label: '手机号' },
{ key: 'gender', label: '性别' },
{ key: 'contractType', label: '合同类型' },
{ key: 'contractStatus', label: '合同状态' },
{ key: 'contractExpiry', label: '合同到期' },
{ key: 'socialStatus', label: '社保状态' },
{ key: 'records', label: '记录' },
] as const
const DEFAULT_VISIBLE = ['department', 'status', 'hireDate', 'position', 'phone', 'gender', 'contractType', 'contractStatus', 'contractExpiry', 'socialStatus']
function useRosterColumns() {
const [visible, setVisible] = useState<string[]>(() => {
try {
const saved = localStorage.getItem('roster-columns')
return saved ? JSON.parse(saved) : DEFAULT_VISIBLE
} catch { return DEFAULT_VISIBLE }
})
const toggle = (key: string) => {
setVisible(prev => {
const next = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
localStorage.setItem('roster-columns', JSON.stringify(next))
return next
})
}
const isVisible = (key: string) => visible.includes(key)
return { isVisible, toggle, visible }
}
export default function Roster() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebouncedValue(search, 300)
@@ -37,8 +73,8 @@ export default function Roster() {
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
const [showDeptModal, setShowDeptModal] = useState(false)
const [deptEmployee, setDeptEmployee] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
const [batchRenewYears, setBatchRenewYears] = useState(3)
@@ -108,7 +144,9 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程')
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
action: { label: '前往处理', onClick: () => navigate('/termination') },
})
setShowResignModal(false)
setResignEmployee(null)
},
@@ -264,10 +302,14 @@ export default function Roster() {
},
})
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
const [showColSettings, setShowColSettings] = useState(false)
const filtered = employees?.filter((e: any) =>
!search || e.name.includes(search) || e.department.includes(search) || (e.idCardMasked && e.idCardMasked.includes(search))
) || []
// 通讯录视图数据
if (selectedId) {
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
}
@@ -275,7 +317,7 @@ export default function Roster() {
return (
<div className="space-y-5">
<PageGuide>
</PageGuide>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
@@ -355,6 +397,30 @@ export default function Roster() {
}} className="h-9 shrink-0">
<Download className="mr-1.5 h-4 w-4" />
</Button>
<div className="relative shrink-0">
<Button variant="secondary" onClick={() => setShowColSettings(v => !v)} className="h-9">
<Settings2 className="mr-1.5 h-4 w-4" />
</Button>
{showColSettings && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowColSettings(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-lg border border-gray-200 bg-white shadow-lg py-2">
<div className="px-3 pb-1 text-xs font-medium text-gray-400"></div>
{ROSTER_COLUMNS.map(col => (
<label key={col.key} className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={colVisible(col.key)}
onChange={() => colToggle(col.key)}
className="rounded border-gray-300"
/>
<span className="text-gray-700">{col.label}</span>
</label>
))}
</div>
</>
)}
</div>
</div>
</div>
@@ -418,37 +484,26 @@ export default function Roster() {
<Card><div className="py-12 text-center text-sm text-gray-400"></div></Card>
) : (
<Card className="overflow-hidden p-0">
<div className="border-b border-gray-100 px-5">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px] text-sm">
<table className="w-full min-w-[900px] text-sm">
<thead className="bg-gray-50/90">
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
<th className="px-4 py-3 text-left w-8">
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
{colVisible('department') && <th className="px-4 py-3 text-left"></th>}
{colVisible('status') && <th className="px-4 py-3 text-left"></th>}
{colVisible('hireDate') && <th className="px-4 py-3 text-left"></th>}
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
<th className="hidden px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
<th className="px-4 py-3 text-center"></th>
{colVisible('position') && <th className="px-4 py-3 text-left"></th>}
{colVisible('phone') && <th className="px-4 py-3 text-left"></th>}
{colVisible('gender') && <th className="px-4 py-3 text-center"></th>}
{colVisible('contractType') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('contractExpiry') && <th className="px-4 py-3 text-left"></th>}
{colVisible('socialStatus') && <th className="px-4 py-3 text-left"></th>}
{colVisible('records') && <th className="px-4 py-3 text-center"></th>}
<th className="px-4 py-3 text-center"></th>
</tr>
</thead>
@@ -462,10 +517,17 @@ export default function Roster() {
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
</td>
<td className="px-4 py-3 font-medium">{e.name}</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{e.idCardMasked || '—'}</td>
<td className="px-4 py-3 text-gray-500">{e.department}</td>
<td className="px-4 py-3">
<td className="px-4 py-3 font-medium">
<div>{e.name}</div>
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败'))
}
}}>{e.idCardMasked || '—'}</div>
</td>
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
{colVisible('status') && <td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
@@ -482,8 +544,8 @@ export default function Roster() {
{e.probationInfo.isExpiring ? `即将到期(${e.probationInfo.daysToConfirm}天)` : `${e.probationInfo.daysToConfirm}`}
</span>
)}
</td>
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
</td>}
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
<td className="hidden px-4 py-3 text-gray-500">
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
@@ -494,8 +556,10 @@ export default function Roster() {
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
<td className="px-4 py-3">
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
{colVisible('contractType') && <td className="px-4 py-3">
{(() => {
const typeConfig: Record<string, { label: string; style: string }> = {
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
@@ -511,8 +575,8 @@ export default function Roster() {
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
})()}
</td>
<td className="px-4 py-3">
</td>}
{colVisible('contractStatus') && <td className="px-4 py-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
@@ -536,8 +600,8 @@ export default function Roster() {
const text = statusTextMap[e.contractStatus] || '无合同'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
})()}
</td>
<td className="hidden px-4 py-3 text-gray-500">
</td>}
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
{(() => {
const endDate = e.latestContract?.endDate
if (!endDate) {
@@ -556,16 +620,42 @@ export default function Roster() {
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays})</span>
return <span className="text-xs">{dateStr}</span>
})()}
</td>
<td className="px-4 py-3 text-center">
{e.counts?.disciplinaryRecords ? (
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
) : <span className="text-gray-300">0</span>}
</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
</td>}
{colVisible('socialStatus') && <td className="px-4 py-3">
{(() => {
const status = e.socialInsuranceStatus
if (!status) return <span className="text-gray-300 text-xs"></span>
const cfg: Record<string, { label: string; style: string }> = {
ACTIVE: { label: '在保', style: 'bg-green-50 text-safe' },
SUSPENDED: { label: '停保', style: 'bg-amber-50 text-amber-600' },
UNINSURED: { label: '未参保', style: 'bg-red-50 text-danger' },
PENDING: { label: '待办理', style: 'bg-blue-50 text-blue-600' },
}
const c = cfg[status] || { label: status, style: 'bg-gray-100 text-gray-500' }
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
})()}
</td>}
{colVisible('records') && <td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1 flex-wrap">
{(() => {
const items = [
{ label: '违纪', count: e.counts?.disciplinaryRecords || 0, danger: true },
{ label: '考勤', count: e.counts?.attendanceRecords || 0 },
{ label: '培训', count: e.counts?.trainingRecords || 0 },
{ label: '绩效', count: e.counts?.performanceRecords || 0 },
{ label: '工资条', count: e.counts?.payslips || 0 },
]
return items.map((it, i) => (
<span
key={i}
className={`text-xs px-1.5 py-0.5 rounded ${it.count > 0 ? (it.danger ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-600') : 'text-gray-300'}`}
>
{it.label}{it.count}
</span>
))
})()}
</div>
</td>}
<td className="px-4 py-3 text-center">
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
<div className="flex items-center justify-center gap-2">
@@ -589,7 +679,7 @@ export default function Roster() {
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
window.location.hash = '#/money'
navigate('/money')
}}
>
<Wallet className="h-4 w-4" />
@@ -667,6 +757,15 @@ export default function Roster() {
</tbody>
</table>
</div>
<div className="border-t border-gray-100 px-5 py-3">
<Pagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
onPageChange={(p) => setPage(p)}
onPageSizeChange={() => setPage(1)}
/>
</div>
</Card>
)}
+118 -75
View File
@@ -4,7 +4,7 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
BarChart3, TrendingUp, TrendingDown, Users, Wallet,
BarChart3, TrendingUp, TrendingDown, Users, Wallet, DollarSign, Gauge,
} from 'lucide-react'
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
@@ -15,11 +15,15 @@ import { salaryDashboardApi } from '../lib/api-services'
/** 金额格式化 */
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const fmtShort = (n: number) => {
if (!n) return '0'
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return n.toFixed(0)
}
export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
@@ -31,17 +35,25 @@ export default function SalaryDashboard() {
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])
const maxMonthly = useMemo(() => {
if (monthlyTrend.length === 0) return 1
return Math.max(...monthlyTrend.map((t: any) => t.total || 0), 1)
}, [monthlyTrend])
const summaryCards = [
{ icon: Users, label: '员工总数', value: summary.totalEmployees || 0, suffix: '', color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-100' },
{ icon: Wallet, label: '月均薪酬', value: `¥${fmt(summary.avgSalary)}`, suffix: '', color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-100' },
{ icon: Gauge, label: '薪酬中位数', value: `¥${fmt(summary.medianSalary)}`, suffix: '', color: 'text-purple-600', bg: 'bg-purple-50', border: 'border-purple-100' },
{ icon: DollarSign, label: '年度总薪酬', value: `¥${fmt(summary.totalAnnual)}`, suffix: '', color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-100' },
]
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -58,6 +70,11 @@ export default function SalaryDashboard() {
</Select>
</div>
{/* 操作说明 — 紧跟标题下方 */}
<PageGuide>
</PageGuide>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
@@ -68,111 +85,137 @@ export default function SalaryDashboard() {
<>
{/* 概览卡片 */}
<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>
{summaryCards.map((card, i) => {
const Icon = card.icon
return (
<Card key={i} className={`p-4 border ${card.border}`}>
<div className="flex items-center justify-between">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${card.bg}`}>
<Icon className={`w-4 h-4 ${card.color}`} />
</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>
<div className="text-xs text-gray-400 mt-2">{card.label}</div>
<div className={`text-xl font-bold mt-0.5 ${card.color}`}>{card.value}{card.suffix}</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'}`}>
<div className="grid grid-cols-2 gap-3">
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${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>
</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'}`}>
<Card className="p-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className={`flex items-center gap-1 text-sm font-bold ${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>
</div>
</Card>
</div>
)}
{/* 部门薪酬对比 + 月度趋势 双栏布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 部门薪酬对比 */}
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{departments.length > 0 && (
<span className="text-xs text-gray-400"> {departments.length} </span>
)}
</div>
{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="space-y-2.5 max-h-[420px] overflow-y-auto pr-1">
{departments.map((dept: any, idx: number) => {
const pct = (dept.avgSalary / maxDeptAvg) * 100
const isTop3 = idx < 3
return (
<div key={dept.name} className="group">
<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 className="flex items-center gap-2 min-w-0">
{isTop3 && (
<span className={`flex-shrink-0 w-4 h-4 rounded text-[10px] flex items-center justify-center font-bold ${
idx === 0 ? 'bg-amber-100 text-amber-700' : idx === 1 ? 'bg-gray-200 text-gray-600' : 'bg-orange-100 text-orange-700'
}`}>{idx + 1}</span>
)}
<span className="text-gray-600 truncate">{dept.name}</span>
</div>
<div className="flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-gray-400">{dept.count}</span>
<span className="text-gray-400"></span>
<span className="font-semibold text-gray-800">¥{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}%` }}
className={`h-full rounded-full transition-all group-hover:opacity-80 ${
isTop3 ? 'bg-gradient-to-r from-primary to-primary/70' : 'bg-primary/40'
}`}
style={{ width: `${pct}%` }}
/>
</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>
{/* 月度趋势 */}
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
{monthlyTrend.length > 0 && (
<span className="text-xs text-gray-400"></span>
)}
</div>
{monthlyTrend.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="relative">
{/* 网格参考线 */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none">
{[0, 1, 2, 3].map(i => (
<div key={i} className="border-t border-dashed border-gray-100" />
))}
</div>
<div className="flex items-end gap-1.5 h-48 relative">
{monthlyTrend.map((m: any) => {
const height = ((m.total || 0) / maxMonthly) * 100
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1 group cursor-pointer">
<div className="text-[10px] text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{m.total ? `¥${fmtShort(m.total)}` : ''}
</div>
<div className="w-full bg-gray-50 rounded-t-md flex-1 flex items-end overflow-hidden">
<div
className="w-full bg-gradient-to-t from-primary/60 to-primary rounded-t-md transition-all group-hover:from-primary group-hover:to-primary/80"
style={{ height: `${height}%` }}
/>
</div>
<div className="text-[10px] text-gray-400">{m.month}</div>
</div>
)
})}
</div>
</div>
)}
</Card>
</div>
{summary.totalEmployees === 0 && (
<InlineAlert type="info">
+36 -51
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -13,7 +14,7 @@ import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import' | 'export'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import' | 'export' | 'display'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -41,6 +42,7 @@ export default function Settings() {
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
{ key: 'display' as const, label: '显示设置', icon: LayoutGrid },
]
return (
@@ -78,12 +80,8 @@ export default function Settings() {
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<ExportSettings />
</Card>
)}
{activeSection === 'export' && <ExportSettings />}
{activeSection === 'display' && <DisplaySettings />}
</div>
)
}
@@ -705,7 +703,6 @@ function PlanSettings({ orgData }: { orgData: any }) {
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const [checkResult, setCheckResult] = useState<string>('')
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
@@ -714,13 +711,6 @@ function NotificationSettings() {
},
})
const { data: logsData } = useQuery<any>({
queryKey: ['notification-logs'],
queryFn: async () => {
return await notificationsApi.logs({ pageSize: 10 })
},
})
useEffect(() => {
if (setting) setForm(setting)
}, [setting])
@@ -730,14 +720,6 @@ function NotificationSettings() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const checkMutation = useMutation({
mutationFn: () => notificationsApi.checkContracts() as any,
onSuccess: (res: any) => {
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
})
const testWechatMutation = useMutation({
mutationFn: () => notificationsApi.test('wechat') as any,
onSuccess: (res: any) => {
@@ -752,8 +734,6 @@ function NotificationSettings() {
},
})
const logs = logsData?.items || []
return (
<div className="space-y-3">
<Card>
@@ -830,31 +810,6 @@ function NotificationSettings() {
</Button>
</div>
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-xs border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-500 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-xs text-center py-4"></div>
)}
</Card>
</div>
)
}
@@ -1335,3 +1290,33 @@ function MonthlyImport() {
</div>
)
}
function DisplaySettings() {
const [size, setSize] = useState(getPageSize())
const handleSave = () => {
setGlobalPageSize(size)
toast.success(`分页大小已设置为 ${size} 条/页`)
}
return (
<Card>
<h2 className="text-sm font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label></Label>
<p className="text-xs text-gray-500 mt-1 mb-2"></p>
<div className="flex items-center gap-3">
<Select value={String(size)} onChange={(e) => setSize(parseInt(e.target.value, 10))} className="!w-32">
<option value="10">10 /</option>
<option value="20">20 /</option>
<option value="50">50 /</option>
<option value="100">100 /</option>
</Select>
<Button size="sm" onClick={handleSave}></Button>
</div>
</div>
</div>
</Card>
)
}
+9 -9
View File
@@ -346,12 +346,12 @@ export default function SocialInsurance() {
<div className="flex items-center gap-2">
<Calculator className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<div className="flex gap-2">
{tab !== 'monthly' && (
{(tab === 'social' || tab === 'housing') && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
@@ -531,7 +531,7 @@ export default function SocialInsurance() {
)}
{/* 调整预览 */}
{tab !== 'monthly' && showAdjust && adjustData && (
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
<Card>
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
@@ -617,7 +617,7 @@ export default function SocialInsurance() {
)}
{/* 版本历史 */}
{tab !== 'monthly' && showVersions && (
{(tab === 'social' || tab === 'housing') && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
@@ -674,7 +674,7 @@ export default function SocialInsurance() {
)}
{/* 新建版本 */}
{tab !== 'monthly' && showNewVersion && (
{(tab === 'social' || tab === 'housing') && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
@@ -918,7 +918,7 @@ export default function SocialInsurance() {
{tab === 'monthly' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<Card>
<div className="flex items-center justify-between mb-3">
@@ -1149,9 +1149,9 @@ export default function SocialInsurance() {
)}
{/* ========== 商险管理 Tab ========== */}
{tab === 'commercial' && (
<div className={tab === 'commercial' ? '' : 'hidden'}>
<CommercialInsuranceTab />
)}
</div>
</div>
)
+2 -1
View File
@@ -3,6 +3,7 @@
* 管理三期(孕期/产期/哺乳期)、工伤、医疗期等特殊状态的跟踪和提醒
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
import { toast } from 'sonner'
import { specialStatusApi, employeeApi } from '../lib/api-services'
@@ -102,8 +103,8 @@ function daysUntil(d: string | null): number | null {
export default function SpecialStatus() {
const [list, setList] = useState<SpecialStatus[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { toast } from 'sonner'
@@ -303,8 +304,8 @@ function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [category, setCategory] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
@@ -465,7 +466,7 @@ function EnterpriseTemplates() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
{/* 编辑弹窗 */}
+60 -3
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
@@ -204,8 +205,8 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const draftPageSize = usePageSize()
const [draftPage, setDraftPage] = useState(1)
const [draftPageSize, setDraftPageSize] = useState(20)
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-for-termination'],
@@ -697,7 +698,7 @@ export default function Termination() {
{view === 'list' && (
<>
<PageGuide>
稿
稿
</PageGuide>
<div className="flex gap-2 flex-wrap items-center">
<Input
@@ -886,7 +887,7 @@ export default function Termination() {
pageSize={draftPageSize}
total={draftsTotal}
onPageChange={setDraftPage}
onPageSizeChange={(s) => { setDraftPageSize(s); setDraftPage(1) }}
onPageSizeChange={() => setDraftPage(1)}
/>
</Card>
</>
@@ -1017,6 +1018,62 @@ export default function Termination() {
</Button>
</div>
)}
{/* 下载离职证明 & 交接清单 */}
{draftDetail.status === 'COMPLETED' && (
<div className="border-t pt-3 flex gap-2 flex-wrap">
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(18)
doc.text('解除/终止劳动合同证明书', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 45
doc.text(`兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),`, 14, y); y += 8
doc.text(`原系我公司 ${draftDetail.department} 部门员工,`, 14, y); y += 8
doc.text(`${draftDetail.terminationDate}${REASONS.find(r => r.value === draftDetail.reason)?.label || draftDetail.reason} 原因,`, 14, y); y += 8
doc.text(`正式解除/终止劳动合同。`, 14, y); y += 8
doc.text(`经济补偿金已结清:¥${fmt(draftDetail.compensation)}`, 14, y); y += 8
doc.text(`社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}`, 14, y); y += 16
doc.text('特此证明。', 14, y); y += 24
doc.text('公司(盖章)', 140, y)
doc.text(new Date().toISOString().slice(0, 10), 140, y + 8)
doc.save(`离职证明-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
{draftDetail.handoverItems && draftDetail.handoverItems.length > 0 && (
<Button
variant="secondary"
onClick={() => {
const doc = new jsPDF()
doc.setFontSize(16)
doc.text('工作交接清单', 105, 25, { align: 'center' })
doc.setFontSize(11)
let y = 40
doc.text(`员工姓名:${draftDetail.employeeName}`, 14, y); y += 8
doc.text(`部门:${draftDetail.department || '—'}`, 14, y); y += 8
doc.text(`离职日期:${draftDetail.terminationDate}`, 14, y); y += 12
doc.setFontSize(10)
draftDetail.handoverItems.forEach((item: any, i: number) => {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(`${item.done ? '[√]' : '[ ]'} ${item.label}${item.remark ? '' + item.remark + '' : ''}`, 14, y); y += 7
})
y += 16
doc.text('交接人签字:____________', 14, y)
doc.text('接收人签字:____________', 100, y)
y += 12
doc.text('日期:____________', 14, y)
doc.save(`交接清单-${draftDetail.employeeName}-${draftDetail.terminationDate}.pdf`)
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
)}
</div>
)}
</div>
</Card>
)}
+306 -24
View File
@@ -1,12 +1,14 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye,
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
} from 'lucide-react'
import { workProcessApi, templatesApi } from '../lib/api-services'
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
@@ -50,7 +52,7 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
}
// 各流程类型的表单字段配置
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template'; options?: string[] }[]> = {
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[] }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
@@ -63,17 +65,17 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
],
ONBOARD: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'hireDate', label: '入职日期', type: 'date' },
],
CUSTOM_CONTRACT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
],
INFO_SUBMIT: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'department', label: '部门', type: 'text' },
{ key: 'phone', label: '手机号', type: 'text' },
{ key: 'address', label: '地址', type: 'text' },
@@ -81,23 +83,25 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
],
CONFIRM: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'confirmDate', label: '转正日期', type: 'date' },
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
],
CHANGE: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'newEndDate', label: '新到期日期', type: 'date' },
],
RENEW: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
{ key: 'newStartDate', label: '新合同开始日期', type: 'date' },
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
{ key: 'newSalary', label: '新薪资', type: 'number' },
],
SUSPEND: [
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
{ key: 'suspendDate', label: '中止日期', type: 'date' },
],
INCOME_CERT: [
@@ -109,14 +113,14 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'purpose', label: '用途', type: 'text' },
],
TERMINATE: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'terminateDate', label: '终止日期', type: 'date' },
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
RESCIND: [
{ key: 'employeeId', label: '员工ID', type: 'text' },
{ key: 'employeeId', label: '选择员工', type: 'employee-select' },
{ key: 'contractId', label: '合同ID', type: 'text' },
{ key: 'rescindDate', label: '解除日期', type: 'date' },
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'] },
@@ -148,6 +152,7 @@ const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat(
}, {} as Record<string, string>)
export default function WorkProcess() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedType, setSelectedType] = useState<string>('')
@@ -156,8 +161,12 @@ export default function WorkProcess() {
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [showBatch, setShowBatch] = useState(false)
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
const [batchSearch, setBatchSearch] = useState('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
@@ -174,7 +183,9 @@ export default function WorkProcess() {
return await workProcessApi.create(data)
},
onSuccess: () => {
toast.success('已创建草稿')
toast.success('已创建草稿,可在列表中查看详情并提交', {
action: { label: '去提交', onClick: () => {} },
})
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowCreate(false)
setFormData({})
@@ -188,7 +199,10 @@ export default function WorkProcess() {
return await workProcessApi.submit(id)
},
onSuccess: () => {
toast.success('已提交并执行')
toast.success('已提交并执行', {
action: { label: '查看文书', onClick: () => navigate('/evidence') },
})
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setDetailId(null)
},
@@ -236,22 +250,100 @@ export default function WorkProcess() {
createMutation.mutate({
type: selectedType,
title: PROCESS_TYPES[selectedType].label,
employeeId: formData.employeeId || undefined,
formData,
status: 'DRAFT',
})
}
const handleCreateAndSubmit = () => {
if (!selectedType) {
toast.error('请选择流程类型')
return
}
createMutation.mutate(
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
{
onSuccess: (data: any) => {
const newId = data?.id
if (newId) {
submitMutation.mutate(newId)
} else {
toast.success('草稿已创建,请手动提交')
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
}
setShowCreate(false)
setFormData({})
setSelectedType('')
},
}
)
}
const handleFieldChange = (key: string, value: any) => {
setFormData(prev => ({ ...prev, [key]: value }))
}
const handleBatchSubmit = () => {
if (batchEmployees.length === 0) {
toast.error('请至少选择一名员工')
return
}
let success = 0
let failed = 0
Promise.all(
batchEmployees.map(async (empId) => {
try {
const emp = allEmployees.find((e: any) => e.id === empId)
if (!emp) return
const data: any = {
type: batchType,
title: PROCESS_TYPES[batchType].label,
employeeId: empId,
formData: {
employeeName: emp.name,
idCardNumber: emp.idCardNumber || '',
position: emp.position || '',
},
status: 'DRAFT',
}
const res: any = await workProcessApi.create(data)
if (res?.id) {
await workProcessApi.submit(res.id)
success++
}
} catch {
failed++
}
})
).then(() => {
toast.success(`批量开具完成:成功 ${success}${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
setShowBatch(false)
setBatchEmployees([])
setBatchSearch('')
})
}
const { data: allEmployees = [] } = useQuery<any[]>({
queryKey: ['employee-list-batch'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filteredEmployees = allEmployees.filter((e: any) => {
if (!batchSearch) return true
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
})
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
@@ -260,10 +352,13 @@ export default function WorkProcess() {
<Button size="sm" onClick={() => setShowCreate(true)}>
<UserPlus className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
<Users className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 13类流程卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
const Icon = PROCESS_ICONS[key] || FileText
return (
@@ -329,10 +424,18 @@ export default function WorkProcess() {
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{item.employee ? `${item.employee.name} · ${item.employee.department}` : '未关联员工'}
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
<button
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<ChevronRight className="w-4 h-4 text-gray-300" />
</div>
)
@@ -344,7 +447,7 @@ export default function WorkProcess() {
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
onPageSizeChange={() => setPage(1)}
/>
</Card>
@@ -388,6 +491,10 @@ export default function WorkProcess() {
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'employee-select' ? (
<EmployeeSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : field.type === 'contract-select' ? (
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
@@ -402,6 +509,10 @@ export default function WorkProcess() {
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
稿
</Button>
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
</Button>
<Button variant="secondary" onClick={() => { setSelectedType(''); setFormData({}) }}>
</Button>
@@ -422,6 +533,61 @@ export default function WorkProcess() {
loading={submitMutation.isPending || cancelMutation.isPending}
/>
</Modal>
{/* 批量开具证明弹窗 */}
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
<div className="space-y-4">
<div>
<Label></Label>
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
<option value="INCOME_CERT"></option>
<option value="LEAVING_CERT"></option>
</Select>
</div>
<div>
<Label> {batchEmployees.length} </Label>
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={batchSearch}
onChange={(e) => setBatchSearch(e.target.value)}
placeholder="搜索姓名或部门..."
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
{filteredEmployees.map((emp: any) => (
<label
key={emp.id}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
>
<input
type="checkbox"
checked={batchEmployees.includes(emp.id)}
onChange={() => {
setBatchEmployees(prev =>
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
)
}}
/>
<span className="text-sm flex-1">{emp.name}</span>
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
</label>
))}
{filteredEmployees.length === 0 && (
<div className="text-center py-4 text-xs text-gray-400"></div>
)}
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}></Button>
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
<Send className="w-4 h-4 mr-1" />
{batchEmployees.length}
</Button>
</div>
</div>
</Modal>
</div>
)
}
@@ -458,7 +624,7 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
</div>
<div className="text-xs text-gray-500">
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : '未关联员工'}
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}${data.employee.department}` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
</div>
</div>
</div>
@@ -489,11 +655,38 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
{data.documents && data.documents.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2"></h4>
<div className="space-y-1">
<div className="space-y-2">
{data.documents.map((doc: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<FileText className="w-3 h-3 text-gray-400" />
<span>{doc.name}</span>
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="flex-1 truncate">{doc.name}</span>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => onPreview(data.id)}
>
<Eye className="w-3.5 h-3.5 inline mr-0.5" />
</button>
<button
type="button"
className="text-primary hover:underline shrink-0"
onClick={() => {
const content = previewContent || ''
if (!content) {
onPreview(data.id)
return
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${doc.name}.txt`
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="w-3.5 h-3.5 inline mr-0.5" />
</button>
</div>
))}
</div>
@@ -549,3 +742,92 @@ function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange
</div>
)
}
function EmployeeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [search, setSearch] = useState('')
const [open, setOpen] = useState(false)
const { data: employees = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-list-for-select'],
queryFn: async () => {
return await employeeApi.allLite()
},
})
const filtered = employees.filter((e: any) => {
if (!search) return true
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
})
const selected = employees.find((e: any) => e.id === value)
return (
<div className="relative">
<div
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
onClick={() => setOpen(!open)}
>
{selected ? (
<span>{selected.name} · {selected.department || '未分配部门'}</span>
) : (
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
)}
<Search className="w-3.5 h-3.5 text-gray-400" />
</div>
{open && (
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
<div className="p-2 border-b border-gray-100">
<input
type="text"
autoFocus
placeholder="搜索姓名/部门/手机号"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="overflow-y-auto max-h-[180px]">
{filtered.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-gray-400"></div>
) : (
filtered.map((e: any) => (
<div
key={e.id}
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
onClick={() => {
onChange(e.id)
setOpen(false)
setSearch('')
}}
>
<span>{e.name}</span>
<span className="text-xs text-gray-400">{e.department || ''}</span>
</div>
))
)}
</div>
</div>
)}
</div>
)
}
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
const { data: contracts = [], isLoading } = useQuery<any[]>({
queryKey: ['employee-contracts', employeeId],
queryFn: async () => {
if (!employeeId) return []
const res = await employeeApi.detail(employeeId)
return res?.contracts || []
},
enabled: !!employeeId,
})
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
return (
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
{activeContracts.map((c: any) => (
<option key={c.id} value={c.id}>
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
</option>
))}
</Select>
)
}
+111 -9
View File
@@ -4,15 +4,17 @@
*/
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
TrendingDown, Calendar, ChevronRight, Filter, CheckSquare,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide'
import QueryError from '../../components/ui/QueryError'
import Pagination from '../../components/ui/Pagination'
import { usePageSize } from '../../hooks/usePageSize'
import { dashboardApi } from '../../lib/api-services'
/** 风险等级配置 */
@@ -23,8 +25,8 @@ const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }>
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster' },
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string; extraParams?: string }> = {
CONTRACT: { label: '合同风险', icon: FileText, link: '/roster', extraParams: 'contractStatus=expired' },
SALARY: { label: '薪酬风险', icon: TrendingDown, link: '/money' },
TERMINATION: { label: '解聘风险', icon: Users, link: '/termination' },
MONTHLY: { label: '月度任务', icon: Calendar, link: '/money' },
@@ -34,7 +36,11 @@ const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatch, setShowBatch] = useState(false)
/** 获取风险列表 */
const { data: risks = [], isLoading, isError, error, refetch } = useQuery<any[]>({
@@ -70,6 +76,35 @@ export default function RiskCenter() {
})
}, [risks, filterLevel, filterType])
/** 构建带筛选参数的跳转链接 */
const buildLink = (r: any) => {
const typeCfg = RISK_TYPES[r.type] || { link: '/', extraParams: '' }
const params: string[] = []
if (r.employee) params.push(`employee=${encodeURIComponent(r.employee.name)}`)
if (typeCfg.extraParams) params.push(typeCfg.extraParams)
return params.length > 0 ? `${typeCfg.link}?${params.join('&')}` : typeCfg.link
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === pagedRisks.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(pagedRisks.map((r: any, i: number) => `${r.type}-${i}`)))
}
}
const total = filteredRisks.length
const pagedRisks = filteredRisks.slice((page - 1) * pageSize, page * pageSize)
return (
<div className="space-y-4">
<PageGuide>
@@ -124,7 +159,7 @@ export default function RiskCenter() {
return (
<Link
key={key}
to={cfg.link}
to={cfg.extraParams ? `${cfg.link}?${cfg.extraParams}` : 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" />
@@ -161,6 +196,26 @@ export default function RiskCenter() {
>{cfg.label}</button>
))}
</div>
<div className="flex gap-1 ml-2">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType('ALL')}
></button>
{Object.entries(RISK_TYPES).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterType === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterType(key)}
>{cfg.label}</button>
))}
</div>
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ml-auto flex items-center gap-1 ${showBatch ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => { setShowBatch(!showBatch); setSelectedIds(new Set()) }}
>
<CheckSquare className="w-3.5 h-3.5" />
{showBatch ? '退出批量' : '批量处理'}
</button>
</div>
{/* 风险列表 */}
@@ -174,17 +229,46 @@ export default function RiskCenter() {
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<>
{showBatch && pagedRisks.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-3 py-2 bg-gray-50 rounded-md">
<button onClick={toggleSelectAll} className="text-xs text-primary hover:underline">
{selectedIds.size === pagedRisks.length ? '取消全选' : '全选'}
</button>
<span className="text-xs text-gray-500"> {selectedIds.size} </span>
{selectedIds.size > 0 && (
<>
<Link to={`/contracts?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline ml-2">
</Link>
<Link to={`/termination?batch=${encodeURIComponent(Array.from(selectedIds).join(','))}`} className="text-xs text-primary hover:underline">
</Link>
</>
)}
</div>
)}
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
{pagedRisks.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
const link = buildLink(r)
const itemId = `${r.type}-${i}`
return (
<Link
<div
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
{showBatch && (
<input
type="checkbox"
checked={selectedIds.has(itemId)}
onChange={() => toggleSelect(itemId)}
className="mt-1 shrink-0"
/>
)}
<Link to={link} className="flex items-start gap-3 flex-1 min-w-0">
<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">
@@ -203,9 +287,27 @@ export default function RiskCenter() {
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
{r.type === 'CONTRACT_EXPIRY' && (
<Link to={`/contracts?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'PROBATION_END' && (
<Link to={`/work-process?type=REGULAR&employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
{r.type === 'TERMINATION_RISK' && (
<Link to={`/termination?employee=${encodeURIComponent(r.employee?.name || '')}`} className="text-xs text-primary hover:underline shrink-0 mt-1">
</Link>
)}
</div>
)
})}
</div>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</>
)}
</Card>
</div>
+7 -6
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
@@ -108,8 +109,8 @@ export function BatchManager() {
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
const [sourceBatchId, setSourceBatchId] = useState<string>('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<string[]>([])
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const { data: checkResult } = useQuery<any>({
queryKey: ['batch-check', month],
@@ -313,7 +314,6 @@ export function BatchManager() {
<EmptyState title="本月暂无发薪批次" description="点击「创建发薪批次」开始" />
) : (
<div className="space-y-3">
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -435,6 +435,7 @@ export function BatchManager() {
</table>
</div>
</Card>
<Pagination page={page} pageSize={pageSize} total={batches.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</div>
)}
</div>
@@ -446,8 +447,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
const confirm = useConfirm()
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
const [editValue, setEditValue] = useState<string>('')
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showAddEmployee, setShowAddEmployee] = useState(false)
const payrollFileRef = useRef<HTMLInputElement>(null)
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
@@ -1013,9 +1014,6 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
{/* 人员表格 */}
<Card>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -1082,6 +1080,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
</tbody>
</table>
</div>
{batch.entries.length > 0 && (
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
)}
</Card>
{/* 定时发送弹窗 */}
+86 -11
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, Check, Layers, Settings as X } from 'lucide-react'
import { Calculator, Check, Layers, Settings as X, Download, Eye } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -17,8 +18,8 @@ export function PayslipManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showTaxPreview, setShowTaxPreview] = useState(false)
const [previewData, setPreviewData] = useState({
baseSalary: 0,
@@ -67,20 +68,86 @@ export function PayslipManager() {
return (
<div className="space-y-3">
<PageGuide>
PDF
PDF
</PageGuide>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-xs">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
<div className="flex items-center gap-2 text-xs flex-nowrap">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 whitespace-nowrap"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe whitespace-nowrap"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning whitespace-nowrap"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['员工', '部门', '基本工资', '加班费', '津贴', '奖金', '扣款', '应发合计', '个税', '实发', '确认状态']
const rows = payslips.map((p: any) => [
p.employee?.name || '',
p.employee?.department || '',
p.baseSalary || 0,
p.overtimePay || 0,
p.allowance || 0,
p.bonus || 0,
p.deduction || 0,
p.totalPay || 0,
p.tax || 0,
p.netPay || 0,
p.confirmedAt ? '已确认' : '未确认',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资表-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资表')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
variant="secondary"
onClick={() => {
if (!payslips || payslips.length === 0) {
toast.error('暂无工资条可导出')
return
}
const headers = ['序号', '收款人姓名', '收款账号', '开户行', '金额', '用途', '备注']
const rows = payslips.map((p: any, i: number) => [
i + 1,
p.employee?.name || '',
p.employee?.bankAccount || '',
p.employee?.bankName || '',
(p.netPay || 0).toFixed(2),
`${month}月工资`,
'',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资流水-${month}.csv`
a.click()
URL.revokeObjectURL(url)
toast.success('已导出工资流水')
}}
>
<Download className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={() => setShowTaxPreview(true)}
>
@@ -107,7 +174,6 @@ export function PayslipManager() {
<Card><div className="text-center py-8 text-gray-500"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -144,8 +210,16 @@ export function PayslipManager() {
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : p.viewedAt ? (
<span className="inline-flex items-center gap-1 text-blue-600 text-xs" title={`查看于 ${new Date(p.viewedAt).toLocaleString('zh-CN')}`}>
<Eye className="w-3 h-3" />
</span>
) : p.publishedAt ? (
<span className="inline-flex items-center gap-1 text-gray-400 text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="py-2 px-2">
@@ -161,6 +235,7 @@ export function PayslipManager() {
</tbody>
</table>
</div>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
</Card>
)}
+2 -1
View File
@@ -2,6 +2,7 @@
*
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Trash2, Edit2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -22,8 +23,8 @@ const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700',
export default function PlatformOrgs() {
const [orgs, setOrgs] = useState<Org[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [planFilter, setPlanFilter] = useState('')
const [loading, setLoading] = useState(true)
@@ -2,6 +2,7 @@
* /
*/
import { useEffect, useState } from 'react'
import { usePageSize } from '../../hooks/usePageSize'
import { Search, Ban, CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
@@ -25,8 +26,8 @@ const ROLE_COLORS: Record<string, string> = {
export default function PlatformUsers() {
const [users, setUsers] = useState<UserItem[]>([])
const [total, setTotal] = useState(0)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [loading, setLoading] = useState(true)
+14 -1
View File
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight,
AlertCircle, ChevronRight, UserX,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -219,6 +219,19 @@ export default function EmployeeHome() {
</div>
</Card>
)}
{/* 辞职申请入口 */}
<Card>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserX className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium"></span>
</div>
<Link to="/portal/resignation" className="text-xs text-primary flex items-center hover:underline">
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</Card>
</div>
)
}
+58 -4
View File
@@ -1,10 +1,10 @@
/**
*
*/
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, FileText } from 'lucide-react'
import { UserX, Clock, FileText, Camera, X, Image as ImageIcon } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
@@ -38,6 +38,8 @@ export default function ResignationApply() {
expectedDate: '',
remark: '',
})
const [attachments, setAttachments] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
/** 查询离职申请状态 */
const { data: records = [], isLoading } = useQuery<any[]>({
@@ -49,13 +51,14 @@ export default function ResignationApply() {
/** 提交离职申请 */
const submitMutation = useMutation({
mutationFn: async (data: { reason: string; expectedDate: string; remark: string }) => {
mutationFn: async (data: { reason: string; expectedDate: string; remark: string; attachments?: string[] }) => {
return await portalApi.resignationSubmit(data)
},
onSuccess: () => {
toast.success('离职申请已提交,请等待HR审批')
queryClient.invalidateQueries({ queryKey: ['portal-resignation-status'] })
setForm({ reason: '', expectedDate: '', remark: '' })
setAttachments([])
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '提交失败')
@@ -79,7 +82,24 @@ export default function ResignationApply() {
const handleSubmit = () => {
if (!form.reason) { toast.error('请选择离职原因'); return }
if (!form.expectedDate) { toast.error('请选择预计离职日期'); return }
submitMutation.mutate(form)
submitMutation.mutate({ ...form, attachments })
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
Array.from(files).forEach(file => {
if (file.size > 5 * 1024 * 1024) {
toast.error(`${file.name} 超过5MB限制`)
return
}
const reader = new FileReader()
reader.onload = () => {
setAttachments(prev => [...prev, reader.result as string])
}
reader.readAsDataURL(file)
})
if (fileInputRef.current) fileInputRef.current.value = ''
}
const hasPending = records.some((r: any) => r.status === 'DRAFT' || r.status === 'PENDING_APPROVAL')
@@ -125,6 +145,40 @@ export default function ResignationApply() {
placeholder="补充说明(选填)"
/>
</div>
<div>
<Label>5</Label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileUpload}
/>
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1 px-3 py-2 rounded-md border border-dashed border-gray-300 text-sm text-gray-500 hover:border-primary hover:text-primary transition-colors"
>
<Camera className="w-4 h-4" />
</button>
{attachments.map((img, i) => (
<div key={i} className="relative w-16 h-16 rounded-md overflow-hidden border">
<img src={img} alt={`附件${i + 1}`} className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setAttachments(prev => prev.filter((_, idx) => idx !== i))}
className="absolute top-0 right-0 bg-black/50 text-white rounded-bl p-0.5"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="text-xs text-gray-400 mt-1">HR审批时可查看</div>
</div>
<Button onClick={handleSubmit} disabled={submitMutation.isPending} className="w-full">
{submitMutation.isPending ? '提交中...' : '提交离职申请'}
</Button>
+4 -4
View File
@@ -10,7 +10,7 @@ import { Paperclip, Trash2, Eye, Download } from "lucide-react"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -49,8 +49,8 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
@@ -65,7 +65,7 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId
<Card>
<div className="flex gap-2 mb-3 flex-nowrap items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
+86 -6
View File
@@ -6,14 +6,14 @@ import { attachmentApi, employeeApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
import { AlertTriangle, Paperclip, Trash2, Eye, Download, Copy } from "lucide-react"
import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
@@ -46,8 +46,8 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', CERTIFICATE: '职业资格证书', CONTRACT: '合同扫描件', PHOTO: '员工照片', OTHER: '其他' }
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', CERTIFICATE: 'bg-purple-50 text-purple-600', CONTRACT: 'bg-cyan-50 text-cyan-600', PHOTO: 'bg-pink-50 text-pink-600', OTHER: 'bg-gray-100 text-gray-500' }
const formatSize = (bytes: number) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes}B`
@@ -195,7 +195,23 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{personalFields.map((f) => (
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
<span className="text-gray-500 shrink-0">{f.label}</span>
<span className="font-medium text-right truncate ml-2">{f.value}</span>
<span className="font-medium text-right truncate ml-2 flex items-center gap-1">
{f.value}
{f.label === '身份证号' && profile.idCardNumber && (
<button
type="button"
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制身份证号"
onClick={() => {
navigator.clipboard.writeText(profile.idCardNumber)
.then(() => toast.success('已复制身份证号'))
.catch(() => toast.error('复制失败'))
}}
>
<Copy className="w-3 h-3" />
</button>
)}
</span>
</div>
))}
</div>
@@ -211,6 +227,70 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
))}
</div>
</div>
{profile.socialInsBase != null && (
<div className="pt-3 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-2"></h3>
<div className="grid md:grid-cols-2 gap-x-6 gap-y-1 text-xs">
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.socialInsBase)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(profile.housingFundBase || 0)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">16%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.16)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.02)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.08)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.5%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.005)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.2%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.002)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">0.8%</span>
<span>¥{fmt((profile.socialInsBase || 0) * 0.008)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between border-b pb-1">
<span className="text-gray-500">7%</span>
<span>¥{fmt((profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.105 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
<div className="flex justify-between font-medium pt-1">
<span></span>
<span className="text-primary">¥{fmt((profile.socialInsBase || 0) * 0.255 + (profile.housingFundBase || 0) * 0.07)}</span>
</div>
</div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-2 gap-3">
@@ -358,7 +438,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
{!editing && (
<div className="flex gap-2 items-center">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="OTHER"></option>
<option value="ID_CARD"></option><option value="BANK_CARD"></option><option value="EDUCATION"></option><option value="CERTIFICATE"></option><option value="CONTRACT"></option><option value="PHOTO"></option><option value="OTHER"></option>
</Select>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">