feat: TurboHR 14项优化与功能增强

- #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 新增公司备用文件上传模块
This commit is contained in:
selfrelease
2026-08-01 13:47:49 +08:00
parent d9e2c610cf
commit f1a02f0439
20 changed files with 542 additions and 32 deletions
+21 -1
View File
@@ -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 {
+2
View File
@@ -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)
+15 -3
View File
@@ -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[文本过长,已截断]'
+1 -1
View File
@@ -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),
})
+72
View File
@@ -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
+17
View File
@@ -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)
+11 -6
View File
@@ -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 || ''
+63 -5
View File
@@ -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 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
@@ -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()
}
+2
View File
@@ -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() {
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
@@ -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 },
],
},
@@ -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' },
]
+8 -2
View File
@@ -1463,9 +1463,9 @@ function ReviewTab() {
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
<input ref={fileInputRef} type="file" accept=".docx,.doc" onChange={handleFileUpload} className="hidden" />
<input ref={fileInputRef} type="file" accept=".docx,.txt,.pdf" onChange={handleFileUpload} className="hidden" />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />...</>) : (<><FileText className="w-4 h-4 mr-1" /> .docx </>)}
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />...</>) : (<><FileText className="w-4 h-4 mr-1" /></>)}
</Button>
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
</div>
@@ -1807,6 +1807,12 @@ function KnowledgeTab() {
return (
<div className="space-y-3">
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-xs text-blue-700 space-y-1">
<div className="font-medium">📖 </div>
<div>· </div>
<div>· AI </div>
<div>· </div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {knowledgeList?.length || 0} </span>
<div className="flex gap-2">
+6 -1
View File
@@ -173,7 +173,12 @@ export default function Calendar() {
<Button variant="secondary" size="sm" onClick={prevMonth}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium min-w-[80px] text-center">{calendarMonth}</span>
<input
type="month"
value={calendarMonth}
onChange={(e) => 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"
/>
<Button variant="secondary" size="sm" onClick={nextMonth}>
<ChevronRight className="w-4 h-4" />
</Button>
+172
View File
@@ -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<string, string> = Object.fromEntries(FILE_TYPES.map(t => [t.value, t.label]))
export default function CompanyFiles() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState('BUSINESS_LICENSE')
const [remark, setRemark] = useState('')
const [expiryDate, setExpiryDate] = useState('')
const [filterType, setFilterType] = useState('')
const { data: files, isLoading } = useQuery<any[]>({
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<HTMLInputElement>) => {
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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
<h1 className="text-lg font-semibold"></h1>
</div>
<Card>
<div className="p-4 space-y-3">
<div className="text-sm text-gray-500"></div>
<div className="flex flex-wrap items-end gap-3">
<div>
<Label></Label>
<Select value={fileType} onChange={(e) => setFileType(e.target.value)} className="w-36">
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="如:2024年营业执照" className="w-48" />
</div>
<div>
<Label></Label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-40" />
</div>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
<Button onClick={() => fileInputRef.current?.click()} disabled={addMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{addMutation.isPending ? '上传中...' : '上传文件'}
</Button>
</div>
</div>
</Card>
<Card>
<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">
<option value=""></option>
{FILE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !files || files.length === 0 ? (
<EmptyState title="暂无文件" description="点击上方上传按钮添加公司文件" />
) : (
<div className="space-y-2">
{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 (
<div key={f.id} className="flex items-center gap-3 p-3 rounded-md border hover:bg-gray-50">
<FileText className="w-5 h-5 text-gray-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{f.fileName}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{fileTypeLabels[f.fileType] || f.fileType}</span>
{isExpired && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-danger flex items-center gap-0.5"><AlertCircle className="w-3 h-3" /></span>}
{isExpiringSoon && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-warning flex items-center gap-0.5"><Calendar className="w-3 h-3" /></span>}
</div>
<div className="text-xs text-gray-400 mt-0.5">
{fmtSize(f.fileSize)}
{f.remark && <span className="ml-2">· {f.remark}</span>}
{f.expiryDate && <span className="ml-2">· {f.expiryDate.slice(0, 10)}</span>}
<span className="ml-2">· {new Date(f.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
</div>
<a href={f.fileUrl} download={f.fileName} className="text-xs text-primary hover:underline flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
</a>
<button
onClick={() => deleteMutation.mutate(f.id)}
className="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-danger"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)
})}
</div>
)}
</div>
</Card>
</div>
)
}
+11 -1
View File
@@ -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<HTMLInputElement>(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<string | null>(null)
const { data: employee } = useQuery<any>({
@@ -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
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="TERMINATION_DOC"></option>
<option value="RETIREMENT_DOC">退</option>
<option value="INJURY_CERT"></option>
<option value="MEDICAL_CERT"></option>
<option value="PREGNANCY_CERT"></option>
<option value="OTHER"></option>
</Select>
<input
+7
View File
@@ -25,6 +25,7 @@ const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; b
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
ONBOARDING: { icon: UserPlus, color: 'text-cyan-600', bg: 'bg-cyan-50' },
RETIREMENT: { icon: Clock, color: 'text-orange-600', bg: 'bg-orange-50' },
}
function TodoIcon({ type }: { type: string; level: string }) {
@@ -981,6 +982,12 @@ export default function Dashboard() {
</Link>
</div>
<div className="flex items-center gap-1">
<Link
to={todo.actionUrl}
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
>
</Link>
<button
onClick={() => resolveMutation.mutate(todo.id)}
disabled={resolveMutation.isPending}
+4
View File
@@ -377,6 +377,8 @@ function BatchManager() {
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
@@ -424,6 +426,8 @@ function BatchManager() {
</td>
<td className="py-2.5 px-3 text-right text-sm">{batch.employeeCount}</td>
<td className="py-2.5 px-3 text-right text-sm font-medium text-primary">¥{fmt(batch.totalPay)}</td>
<td className="py-2.5 px-3 text-right text-sm text-amber-600">¥{fmt((batch.totalSocialOrg || 0) + (batch.totalSocialEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
<td className="py-2.5 px-3">
+2 -2
View File
@@ -159,8 +159,8 @@ export default function SpecialStatus() {
const fetchEmployees = async () => {
try {
const res = await api.get('/employees', { params: { pageSize: 999 } }) as any
setEmployees(res.data.list || [])
const res = await api.get('/employees/all-lite') as any
setEmployees(res.data || [])
} catch {
// 忽略
}
+123 -7
View File
@@ -17,13 +17,105 @@ import Pagination from '../components/ui/Pagination'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const REASONS = [
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
{ value: 'NONFAULT', label: '员工没犯错但干不了(生病/不胜任等)', legalBasis: '《劳动合同法》第40条' },
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2', legalBasis: '《劳动合同法》第87条' },
{ value: 'RESIGNATION', label: '员工主动离职', legalBasis: '《劳动合同法》第37条' },
{
value: 'NEGOTIATED',
label: '协商解除(双方同意分开了)',
legalBasis: '《劳动合同法》第36条',
legalDetail: '用人单位与劳动者协商一致,可以解除劳动合同。需支付经济补偿(N),工作满1年支付1个月工资。',
steps: [
'第一步:与员工进行协商沟通,达成解除意向',
'第二步:签订《协商解除劳动合同协议书》,明确解除日期、补偿金额、支付方式',
'第三步:按协议约定支付经济补偿金',
'第四步:出具《解除劳动合同证明书》',
'第五步:办理工作交接、社保减员、档案转移',
],
},
{
value: 'FAULT',
label: '员工犯错被辞退(严重违纪/失职等)',
legalBasis: '《劳动合同法》第39条',
legalDetail: '劳动者有下列情形之一的,用人单位可以解除劳动合同:(1)在试用期间被证明不符合录用条件的;(2)严重违反用人单位的规章制度的;(3)严重失职,营私舞弊,给用人单位造成重大损害的;(4)同时与其他用人单位建立劳动关系,对完成本单位的工作任务造成严重影响,或经提出拒不改正的;(5)以欺诈、胁迫等手段致使劳动合同无效的;(6)被依法追究刑事责任的。无需支付经济补偿。',
steps: [
'第一步:收集并固化证据(违纪事实、制度依据、证人证言等)',
'第二步:确认规章制度经过民主程序制定并已向员工公示(签收记录)',
'第三步:将解除理由通知工会,工会提出意见后书面回复工会',
'第四步:向员工送达《解除劳动合同通知书》,注明解除依据和事实',
'第五步:办理工作交接、社保减员、档案转移',
'⚠ 注意:证据不足或制度未公示可能导致违法解除,建议咨询律师',
],
},
{
value: 'NONFAULT',
label: '员工没犯错但干不了(生病/不胜任等)',
legalBasis: '《劳动合同法》第40条',
legalDetail: '有下列情形之一的,用人单位提前三十日以书面形式通知劳动者本人或者额外支付一个月工资后,可以解除劳动合同:(1)劳动者患病或非因工负伤,在规定的医疗期满后不能从事原工作,也不能从事由用人单位另行安排的工作的;(2)劳动者不能胜任工作,经过培训或者调整工作岗位,仍不能胜任工作的。需支付经济补偿(N)+代通知金(1个月)。',
steps: [
'第一步(医疗期满):确认医疗期已满,安排劳动能力鉴定',
'第一步(不胜任):进行绩效考核,确认不胜任事实',
'第二步(医疗期满):另行安排合适岗位,员工仍不能胜任',
'第二步(不胜任):进行培训或调岗,再次考核仍不胜任',
'第三步:提前30天书面通知员工,或额外支付1个月代通知金',
'第四步:支付经济补偿金(N)',
'第五步:出具解除证明,办理交接、社保减员',
],
},
{
value: 'LAYOFF',
label: '公司裁员(经营困难/技术调整等)',
legalBasis: '《劳动合同法》第41条',
legalDetail: '有下列情形之一,需要裁减人员二十人以上或不足二十人但占企业职工总数百分之十以上的,用人单位提前三十日向工会或全体职工说明情况,听取意见后,向劳动行政部门报告,可以裁减人员:(1)依照企业破产法规定进行重整的;(2)生产经营发生严重困难的;(3)企业转产、重大技术革新或经营方式调整,经变更劳动合同仍需裁减人员的。需支付经济补偿(N)。',
steps: [
'第一步:确认符合法定裁员情形,准备相关证明材料',
'第二步:提前30天向工会或全体职工说明情况(书面会议记录)',
'第三步:听取工会或职工意见,形成意见处理方案',
'第四步:向当地劳动行政部门报告裁员方案',
'第五步:确定裁员名单(优先留用法定保护人员)',
'第六步:向员工送达解除通知,支付经济补偿金(N)',
'第七步:办理交接、社保减员、档案转移',
],
},
{
value: 'EXPIRED',
label: '合同到期不续签',
legalBasis: '《劳动合同法》第44条、第46条',
legalDetail: '劳动合同期满,劳动合同终止。除用人单位维持或提高劳动合同约定条件续订劳动合同,劳动者不同意续订的情形外,用人单位应当向劳动者支付经济补偿(N)。连续订立两次固定期限劳动合同后续订的,应当订立无固定期限劳动合同。',
steps: [
'第一步:确认合同到期日期,提前评估是否续签',
'第二步:如不续签,提前通知员工(建议提前30天)',
'第三步:确认是否属于法定应续签无固定期限合同的情形',
'第四步:支付经济补偿金(N),工作满1年支付1个月工资',
'第五步:出具《终止劳动合同证明书》',
'第六步:办理交接、社保减员、档案转移',
],
},
{
value: 'ILLEGAL',
label: '违法解除(赔偿金×2',
legalBasis: '《劳动合同法》第87条',
legalDetail: '用人单位违反本法规定解除或终止劳动合同的,应当依照本法第47条规定的经济补偿标准的二倍向劳动者支付赔偿金(2N)。',
steps: [
'第一步:确认违法解除事实(未走法定程序、证据不足等)',
'第二步:与员工协商赔偿金额,尽量达成一致',
'第三步:如员工要求继续履行合同,应恢复劳动关系',
'第四步:如无法恢复,支付2倍经济补偿作为赔偿金',
'第五步:办理交接、社保减员、档案转移',
'⚠ 建议:违法解除风险极高,操作前务必咨询专业律师',
],
},
{
value: 'RESIGNATION',
label: '员工主动离职',
legalBasis: '《劳动合同法》第37条',
legalDetail: '劳动者提前三十日以书面形式通知用人单位,可以解除劳动合同。劳动者在试用期内提前三日通知用人单位,可以解除劳动合同。无需支付经济补偿。',
steps: [
'第一步:接收员工书面辞职信(确认日期和签字)',
'第二步:确认提前30天通知(试用期3天)',
'第三步:安排工作交接计划',
'第四步:结算未发放工资、未休年假补偿等',
'第五步:出具《解除劳动合同证明书》',
'第六步:办理社保减员、档案转移',
],
},
]
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '工作交接', '确认提交']
@@ -1087,6 +1179,30 @@ export default function Termination() {
)
})}
</div>
{/* 法律条款详情 + 操作步骤 */}
{reason && (() => {
const r = REASONS.find((x) => x.value === reason)
if (!r) return null
return (
<div className="border border-gray-200 rounded-md p-4 space-y-3 bg-gray-50/50">
<div>
<div className="text-xs font-medium text-gray-700 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-primary" />
{r.legalBasis}
</div>
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{r.legalDetail}</p>
</div>
<div className="border-t border-gray-200 pt-2">
<div className="text-xs font-medium text-gray-700 mb-1.5">📋 </div>
<ol className="space-y-1">
{r.steps.map((s, i) => (
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
))}
</ol>
</div>
</div>
)
})()}
<div>
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
+3 -3
View File
@@ -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