From f1a02f0439c362dc193acaf684a22b269aa3e0b2 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 1 Aug 2026 13:47:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20TurboHR=2014=E9=A1=B9=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E5=8A=9F=E8=83=BD=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #1 Dashboard风险提醒增加立刻办理按钮 - #2 Calendar月份选择器改为input month - #3 Termination增加7种解聘原因法律依据和操作步骤 - #5 合同审查支持PDF TXT格式 - #6 AI合同审查prompt优化为具体修改建议 - #7 知识库添加更新机制说明 - #9 SpecialStatus员工选择改用all-lite接口 - #10 Termination增加详细法律条款引用 - #11 Money发薪批次增加社保公积金合计列 - #12 EmployeeAttachment扩展文件类型 - #13 花名册增加女职工干部工人选项加退休提醒 - #14 新增公司备用文件上传模块 --- backend/prisma/schema.prisma | 22 ++- backend/src/app.ts | 2 + backend/src/routes/ai.routes.ts | 18 +- backend/src/routes/attachment.routes.ts | 2 +- backend/src/routes/company-file.routes.ts | 72 ++++++++ backend/src/routes/employee.routes.ts | 17 ++ backend/src/services/ai.service.ts | 17 +- backend/src/services/risk.service.ts | 68 ++++++- frontend/src/App.tsx | 2 + frontend/src/components/layout/SidebarNav.tsx | 1 + frontend/src/components/ui/CommandPalette.tsx | 1 + frontend/src/pages/AIAssistant.tsx | 10 +- frontend/src/pages/Calendar.tsx | 7 +- frontend/src/pages/CompanyFiles.tsx | 172 ++++++++++++++++++ frontend/src/pages/Contracts.tsx | 12 +- frontend/src/pages/Dashboard.tsx | 7 + frontend/src/pages/Money.tsx | 4 + frontend/src/pages/SpecialStatus.tsx | 4 +- frontend/src/pages/Termination.tsx | 130 ++++++++++++- frontend/src/types/index.ts | 6 +- 20 files changed, 542 insertions(+), 32 deletions(-) create mode 100644 backend/src/routes/company-file.routes.ts create mode 100644 frontend/src/pages/CompanyFiles.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 90eed69..51657a0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,6 +55,7 @@ enum RiskType { TERMINATION MONTHLY ONBOARDING + RETIREMENT } enum RiskLevel { @@ -180,6 +181,7 @@ model Organization { enterpriseTemplates EnterpriseTemplate[] attendancePublishes AttendancePublish[] specialStatuses EmployeeSpecialStatus[] + companyFiles CompanyFile[] } model User { @@ -503,7 +505,7 @@ model EmployeeAttachment { employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) fileName String - fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / DISCIPLINARY / OTHER + fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / TERMINATION_DOC / RETIREMENT_DOC / INJURY_CERT / MEDICAL_CERT / PREGNANCY_CERT / DISCIPLINARY / OTHER fileUrl String fileSize Int @default(0) // 关联违纪记录(可选) @@ -515,6 +517,24 @@ model EmployeeAttachment { @@index([disciplinaryRecordId]) } +// ========== 公司备用文件 ========== +model CompanyFile { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + fileName String + fileType String // BUSINESS_LICENSE=营业执照 / WORK_HOURS=工时备案 / HR_POLICY=制度文件 / LABOR_CONTRACT_TEMPLATE=合同模板 / OTHER=其他 + fileUrl String + fileSize Int @default(0) + remark String? // 备注 + expiryDate DateTime? // 有效期(如营业执照到期日) + uploadedBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, fileType]) +} + // ========== 仲裁证据链 ========== model DisciplinaryRecord { diff --git a/backend/src/app.ts b/backend/src/app.ts index c6dc00e..29fd195 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -61,6 +61,7 @@ import platformRoutes from './routes/platform.routes' import workProcessRoutes from './routes/work-process.routes' import enterpriseTemplateRoutes from './routes/enterprise-template.routes' import specialStatusRoutes from './routes/special-status.routes' +import companyFileRoutes from './routes/company-file.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -86,6 +87,7 @@ app.use('/api/v1/platform', platformRoutes) app.use('/api/v1/work-processes', workProcessRoutes) app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes) app.use('/api/v1/special-statuses', specialStatusRoutes) +app.use('/api/v1/company-files', companyFileRoutes) app.use(errorHandler) diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 2369046..924773f 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -957,7 +957,7 @@ const reviewUpload = multer({ limits: { fileSize: 100 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() - if (ext !== '.docx' && ext !== '.doc') { + if (ext !== '.docx' && ext !== '.txt' && ext !== '.pdf') { return cb(null, false) } cb(null, true) @@ -967,15 +967,27 @@ const reviewUpload = multer({ router.post('/review/upload', authMiddleware, reviewUpload.single('file'), async (req: AuthRequest, res, next) => { try { if (!req.file) { - return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx 文件' } }) + return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx / .txt / .pdf 文件' } }) } const ext = path.extname(req.file.originalname).toLowerCase() let text = '' if (ext === '.docx') { const result = await mammoth.extractRawText({ buffer: req.file.buffer }) text = result.value + } else if (ext === '.txt') { + text = req.file.buffer.toString('utf-8') + } else if (ext === '.pdf') { + // PDF 简单文本提取:提取括号内的文本流内容 + const raw = req.file.buffer.toString('latin1') + const textMatches = raw.match(/\(([^)]+)\)/g) + if (textMatches) { + text = textMatches.map(m => m.slice(1, -1).replace(/\\[nrt()\\]/g, ' ')).join(' ') + } + if (!text || text.trim().length < 10) { + return res.status(400).json({ success: false, error: { code: 'PDF_PARSE_FAIL', message: 'PDF 文件无法提取文本,可能是扫描件或图片格式。建议将文件另存为 .docx 后上传' } }) + } } else { - return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } }) + return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持该格式,请上传 .docx / .txt / .pdf 文件' } }) } if (text.length > 50000) { text = text.slice(0, 50000) + '\n\n[文本过长,已截断]' diff --git a/backend/src/routes/attachment.routes.ts b/backend/src/routes/attachment.routes.ts index 138ff77..4741ae7 100644 --- a/backend/src/routes/attachment.routes.ts +++ b/backend/src/routes/attachment.routes.ts @@ -23,7 +23,7 @@ router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFun const attachmentSchema = z.object({ employeeId: z.string().min(1), fileName: z.string().min(1), - fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']), + fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'TERMINATION_DOC', 'RETIREMENT_DOC', 'INJURY_CERT', 'MEDICAL_CERT', 'PREGNANCY_CERT', 'DISCIPLINARY', 'OTHER']), fileUrl: z.string().min(1), fileSize: z.number().int().default(0), }) diff --git a/backend/src/routes/company-file.routes.ts b/backend/src/routes/company-file.routes.ts new file mode 100644 index 0000000..4e59382 --- /dev/null +++ b/backend/src/routes/company-file.routes.ts @@ -0,0 +1,72 @@ +import { Router, Response, NextFunction } from 'express' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { z } from 'zod' + +const router = Router() +router.use(authMiddleware) + +/** + * 公司备用文件管理路由 + * 支持营业执照、工时备案、制度文件、合同模板等公司级文件上传 + */ + +// 获取公司文件列表 +router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const fileType = req.query.fileType as string | undefined + const files = await prisma.companyFile.findMany({ + where: { orgId: req.user!.orgId, ...(fileType ? { fileType } : {}) }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: files }) + } catch (err) { + next(err) + } +}) + +// 添加公司文件记录 +const companyFileSchema = z.object({ + fileName: z.string().min(1), + fileType: z.enum(['BUSINESS_LICENSE', 'WORK_HOURS', 'HR_POLICY', 'LABOR_CONTRACT_TEMPLATE', 'OTHER']), + fileUrl: z.string().min(1), + fileSize: z.number().int().default(0), + remark: z.string().optional(), + expiryDate: z.string().optional(), +}) + +router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = companyFileSchema.parse(req.body) + const { expiryDate, ...rest } = data + const file = await prisma.companyFile.create({ + data: { + orgId: req.user!.orgId, + ...rest, + ...(expiryDate ? { expiryDate: new Date(expiryDate) } : {}), + uploadedBy: req.user!.id, + }, + }) + res.json({ success: true, data: file }) + } catch (err) { + next(err) + } +}) + +// 删除公司文件 +router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const file = await prisma.companyFile.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!file) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '文件不存在' } }) + } + await prisma.companyFile.delete({ where: { id: file.id } }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index 1ca3c1e..95166a1 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -36,6 +36,23 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { } }) +/** + * 轻量级全量员工列表(不分页,仅返回 id/name/department/gender/status) + * 用于特殊状态台账、发薪批次等需要选择全部员工的场景 + */ +router.get('/all-lite', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const employees = await prisma.employee.findMany({ + where: { orgId: req.user!.orgId, status: { in: ['ACTIVE', 'RESIGNED'] } }, + select: { id: true, name: true, department: true, gender: true, status: true }, + orderBy: { name: 'asc' }, + }) + res.json({ success: true, data: employees }) + } catch (err) { + next(err) + } +}) + router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const employee = await getEmployeeDetail(req.user!.orgId, req.params.id) diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index ef5204d..2049db8 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -84,28 +84,33 @@ export async function* chatStream(messages: { role: 'user' | 'assistant'; conten } export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> { - const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。 + const prompt = `请审查以下劳动合同文本的合法性和合规性,逐条检查并标注风险等级(红/黄/绿),对每个风险点必须给出: +1. 问题说明:具体哪一条款存在什么问题 +2. 法律依据:引用《劳动合同法》具体条款 +3. 具体修改建议:给出可以直接替换的修改后条款文本 + +最后给出合规评分(0-100分)。 合同文本: ${contractText} -请按以下格式输出: +请严格按以下格式输出: 【风险项】 -🔴/🟡/🟢 [问题标题] - [说明] - [修改建议] +🔴/🟡/🟢 [问题标题] - [问题说明+法律依据] - [具体修改建议:应将xxx修改为yyy] 【合规评分】XX/100 【总体建议】 -一段话总结` +一段话总结,指出最需要优先修改的3个问题` const response = await client.chat.completions.create({ model: 'qwen-max', messages: [ - { role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' }, + { role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。对每个风险点必须给出法律依据和可直接替换的具体修改建议文本。' }, { role: 'user', content: prompt }, ], temperature: 0.3, - max_tokens: 3000, + max_tokens: 4000, }) const text = response.choices[0]?.message?.content || '' diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 127e8b2..d715bc6 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -1,6 +1,7 @@ import prisma from '../lib/prisma' import type { RiskLevel, RiskType } from '@prisma/client' import { decrypt } from '../lib/crypto' +import { calcIndividualRetireAge, calcRetirementDaysLeft } from './retirement.service' function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) @@ -119,6 +120,17 @@ function estimateRiskCost( } } + // 退休提醒:未及时办理退休可能导致多缴社保公积金 + if (title.includes('退休')) { + const daysMatch = title.match(/(\d+)天/) + const days = daysMatch ? parseInt(daysMatch[1]) : 0 + return { + estimatedLoss: days > 0 ? salary * (days / 30) : salary, + lossRange: [500, salary * 6], + deadline: new Date(today.getTime() + (days > 0 ? days : 7) * 86400000), + } + } + // 默认 return { estimatedLoss: 0, @@ -452,6 +464,49 @@ export async function detectMonthlyTasks(orgId: string) { return risks } +/** + * 退休提醒:检测即将退休的员工(距退休180天内) + */ +export async function detectRetirementRisks(orgId: string) { + const employees = await prisma.employee.findMany({ + where: { orgId, status: 'ACTIVE', birthDate: { not: null } }, + select: { id: true, name: true, gender: true, birthDate: true, femaleWorkerType: true }, + }) + + const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] + + for (const emp of employees) { + if (!emp.birthDate) continue + const gender = emp.gender || '男' + const fwt = emp.femaleWorkerType + const bd = new Date(emp.birthDate) + const { retireDate } = calcIndividualRetireAge(bd, gender, fwt) + const daysLeft = calcRetirementDaysLeft(bd, gender, fwt) ?? 0 + + if (daysLeft <= 180 && daysLeft > 0) { + risks.push({ + employeeId: emp.id, + type: 'RETIREMENT', + level: daysLeft <= 30 ? 'HIGH' : 'MEDIUM', + title: `${emp.name}距退休仅剩${daysLeft}天`, + description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},预计退休日期 ${retireDate.toISOString().slice(0, 10)}。请提前准备退休手续。`, + actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, + }) + } else if (daysLeft <= 0) { + risks.push({ + employeeId: emp.id, + type: 'RETIREMENT', + level: 'HIGH', + title: `${emp.name}已达退休年龄`, + description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},已超过退休日期 ${retireDate.toISOString().slice(0, 10)}。请尽快办理退休手续。`, + actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, + }) + } + } + + return risks +} + export async function runRiskDetection(orgId: string) { // 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建 // 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同) @@ -481,9 +536,10 @@ export async function runRiskDetection(orgId: string) { const onboardingRisks = await detectOnboardingRisks(orgId) const monthlyTasks = await detectMonthlyTasks(orgId) const specialStatusRisks = await detectSpecialStatusRisks(orgId) + const retirementRisks = await detectRetirementRisks(orgId) // 获取所有相关员工数据用于风险量化 - const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks] + const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks] .map(r => r.employeeId) .filter(Boolean) as string[] const employees = allEmployeeIds.length > 0 @@ -492,7 +548,7 @@ export async function runRiskDetection(orgId: string) { const empMap = new Map(employees.map(e => [e.id, e])) // 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重 - const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks] + const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks] const toCreate = [ ...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}`)), ...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)), @@ -790,7 +846,7 @@ export async function getDashboardData(orgId: string) { const daysUntilDeadline = r.deadline ? daysBetween(r.deadline, new Date()) : null return { id: r.id, - type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY', + type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', priority, title: r.title, @@ -821,6 +877,7 @@ export async function getDashboardData(orgId: string) { if (title.includes('公积金')) return 'MONTHLY_HOUSING' if (title.includes('工资')) return 'MONTHLY_PAYROLL' if (title.includes('个税')) return 'MONTHLY_TAX' + if (title.includes('退休')) return 'RETIREMENT' return title.replace(/\d+/g, '').trim() } @@ -872,7 +929,7 @@ export async function getDashboardData(orgId: string) { const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({ id: r.id, - type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY', + type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', title: r.title, description: r.description, @@ -906,7 +963,7 @@ export async function getDashboardData(orgId: string) { greeting, stats: { employeeCount, - highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION')).length, + highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'RETIREMENT')).length, todoCount: todos.length, monthlyOvertimePay, }, @@ -1818,6 +1875,7 @@ export async function getAnnualValueReport(orgId: string, year: number) { if (title.includes('公积金')) return 'MONTHLY_HOUSING' if (title.includes('工资')) return 'MONTHLY_PAYROLL' if (title.includes('个税')) return 'MONTHLY_TAX' + if (title.includes('退休')) return 'RETIREMENT' return title.replace(/\d+/g, '').trim() } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85e39f5..34e6fea 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,6 +41,7 @@ const CalendarPage = lazy(() => import('./pages/Calendar')) const WorkProcess = lazy(() => import('./pages/WorkProcess')) const MyAttendance = lazy(() => import('./pages/portal/MyAttendance')) const SpecialStatus = lazy(() => import('./pages/SpecialStatus')) +const CompanyFiles = lazy(() => import('./pages/CompanyFiles')) // Sprint 4-5 新增页面 const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome')) @@ -189,6 +190,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 20402b2..f456100 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -77,6 +77,7 @@ const navGroups: NavGroup[] = [ { path: '/templates', label: '文本模板', icon: BookMarked }, { path: '/notifications', label: '通知管理', icon: Bell }, { path: '/audit', label: '操作日志', icon: ScrollText }, + { path: '/company-files', label: '公司文件', icon: Building2 }, { path: '/settings', label: '设置', icon: Settings }, ], }, diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index 0a5a63a..6378925 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -27,6 +27,7 @@ const QUICK_PAGES: SearchResult[] = [ { type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' }, { type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' }, { type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' }, + { type: 'page', id: 'company-files', title: '公司文件', link: '/company-files', icon: 'building' }, { type: 'page', id: 'settings', title: '设置', link: '/settings', icon: 'gear' }, ] diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 841a094..431e77d 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1463,9 +1463,9 @@ function ReviewTab() { - + {fileName && {fileName}} @@ -1807,6 +1807,12 @@ function KnowledgeTab() { return (
+
+
📖 知识库说明
+
· 法律法规知识库由研发方定期更新维护,确保政策时效性
+
· 您可点击「添加知识」上传企业内部制度、操作规范等,AI 问答将同时检索法律法规和企业制度
+
· 如发现法律内容过时,请联系研发方更新
+
共 {knowledgeList?.length || 0} 条知识
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index aeb8037..2a0a04d 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -173,7 +173,12 @@ export default function Calendar() { - {calendarMonth} + setCalendarMonth(e.target.value)} + className="text-sm font-medium rounded-md border border-input bg-background px-2 py-1 min-w-[120px] text-center" + /> diff --git a/frontend/src/pages/CompanyFiles.tsx b/frontend/src/pages/CompanyFiles.tsx new file mode 100644 index 0000000..6560cae --- /dev/null +++ b/frontend/src/pages/CompanyFiles.tsx @@ -0,0 +1,172 @@ +import { useState, useRef } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { Building2, Upload, Trash2, FileText, AlertCircle, Calendar } from 'lucide-react' +import api from '../lib/api' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import { Input, Label, Select } from '../components/ui/Input' +import EmptyState from '../components/ui/EmptyState' + +const FILE_TYPES = [ + { value: 'BUSINESS_LICENSE', label: '营业执照' }, + { value: 'WORK_HOURS', label: '工时备案' }, + { value: 'HR_POLICY', label: '制度文件' }, + { value: 'LABOR_CONTRACT_TEMPLATE', label: '合同模板' }, + { value: 'OTHER', label: '其他' }, +] + +const fileTypeLabels: Record = Object.fromEntries(FILE_TYPES.map(t => [t.value, t.label])) + +export default function CompanyFiles() { + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [fileType, setFileType] = useState('BUSINESS_LICENSE') + const [remark, setRemark] = useState('') + const [expiryDate, setExpiryDate] = useState('') + const [filterType, setFilterType] = useState('') + + const { data: files, isLoading } = useQuery({ + queryKey: ['company-files', filterType], + queryFn: async () => { + const res = await api.get('/company-files', { params: filterType ? { fileType: filterType } : {} }) as any + return res.data || [] + }, + }) + + const addMutation = useMutation({ + mutationFn: (data: any) => api.post('/company-files', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['company-files'] }) + toast.success('文件上传成功') + setRemark('') + setExpiryDate('') + }, + onError: () => toast.error('上传失败'), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/company-files/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['company-files'] }) + toast.success('已删除') + }, + }) + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + if (file.size > 10 * 1024 * 1024) { + toast.error('文件不能超过 10MB') + return + } + const reader = new FileReader() + reader.onload = () => { + addMutation.mutate({ + fileName: file.name, + fileType, + fileUrl: reader.result as string, + fileSize: file.size, + remark: remark || undefined, + expiryDate: expiryDate || undefined, + }) + } + reader.readAsDataURL(file) + e.target.value = '' + } + + const fmtSize = (bytes: number) => { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / 1024 / 1024).toFixed(1)}MB` + } + + return ( +
+
+ +

公司备用文件

+
+ + +
+
上传营业执照、工时备案文件、公司制度文件、合同模板等公司级文件
+
+
+ + +
+
+ + setRemark(e.target.value)} placeholder="如:2024年营业执照" className="w-48" /> +
+
+ + setExpiryDate(e.target.value)} className="w-40" /> +
+ + +
+
+
+ + +
+
+

文件列表

+ +
+ {isLoading ? ( +
加载中...
+ ) : !files || files.length === 0 ? ( + + ) : ( +
+ {files.map((f: any) => { + const isExpired = f.expiryDate && new Date(f.expiryDate) < new Date() + const isExpiringSoon = f.expiryDate && !isExpired && (new Date(f.expiryDate).getTime() - new Date().getTime()) < 30 * 24 * 60 * 60 * 1000 + return ( +
+ +
+
+ {f.fileName} + {fileTypeLabels[f.fileType] || f.fileType} + {isExpired && 已过期} + {isExpiringSoon && 即将到期} +
+
+ {fmtSize(f.fileSize)} + {f.remark && · {f.remark}} + {f.expiryDate && · 有效期至 {f.expiryDate.slice(0, 10)}} + · {new Date(f.createdAt).toLocaleDateString('zh-CN')} +
+
+ + 下载 + + +
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index 6d67264..4801729 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -380,7 +380,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) { const queryClient = useQueryClient() const fileInputRef = useRef(null) - const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD') + const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'TERMINATION_DOC' | 'RETIREMENT_DOC' | 'INJURY_CERT' | 'MEDICAL_CERT' | 'PREGNANCY_CERT' | 'OTHER'>('ID_CARD') const [previewUrl, setPreviewUrl] = useState(null) const { data: employee } = useQuery({ @@ -447,6 +447,11 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC BANK_CARD: '银行卡', CONTRACT_SCAN: '合同附件', EDUCATION: '学历证书', + TERMINATION_DOC: '解除文件', + RETIREMENT_DOC: '退休档案', + INJURY_CERT: '工伤认定', + MEDICAL_CERT: '医疗期证明', + PREGNANCY_CERT: '三期证明', OTHER: '其他', } @@ -523,6 +528,11 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC + + + + +
+ + 立刻办理 → +
+ {/* 法律条款详情 + 操作步骤 */} + {reason && (() => { + const r = REASONS.find((x) => x.value === reason) + if (!r) return null + return ( +
+
+
+ + 法律依据:{r.legalBasis} +
+

{r.legalDetail}

+
+
+
📋 规范操作流程
+
    + {r.steps.map((s, i) => ( +
  1. {s}
  2. + ))} +
+
+
+ ) + })()}
setTerminationDate(e.target.value)} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 448bdeb..5551f5e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -85,7 +85,7 @@ export interface DashboardData { } urgentRisk: { id: string - type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' + type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT' level: 'high' | 'medium' | 'low' priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' title: string @@ -98,7 +98,7 @@ export interface DashboardData { } | null todos: { id: string - type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' + type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT' level: 'high' | 'medium' | 'low' priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' title: string @@ -113,7 +113,7 @@ export interface DashboardData { }[] resolvedTodos: { id: string - type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' + type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT' level: 'high' | 'medium' | 'low' title: string description: string