feat: 北京解聘合规增强 — 政策法规库+地区过滤+工会回执证据链

1. 政策法规库(RAG知识库):
   - 新增6条北京地区单方解除劳动合同工作指引种子数据
   - 涵盖通知工会程序、函件内容要求、回执要求、监督提示函、仲裁审查等
   - 企业用户通过AI问答可检索到北京地区工会通知规定

2. 地区差异化合规检查:
   - getChecklistForReason 增加 orgCity 参数
   - 工会通知检查项仅北京地区显示(FAULT/NONFAULT/LAYOFF)
   - 前端解聘方式说明中北京工会提示仅北京地区动态显示
   - 非北京地区不显示工会通知检查项,避免误导

3. 工会回执上传+证据链留存:
   - 后端新增3个接口:上传回执文件、保存回执信息、获取回执信息
   - 回执信息保存到草稿 checklistOverrides
   - 自动追加到证据链(appendEvidence),作为劳动仲裁举证材料
   - 前端合规检查步骤增加工会回执上传区域
   - 确认提交步骤展示回执文件链接
   - 新增 uploads 静态文件服务

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 16:01:02 +08:00
parent e8c6d27979
commit 7e720d9bfc
6 changed files with 332 additions and 34 deletions
+4
View File
@@ -1,4 +1,5 @@
import express from 'express' import express from 'express'
import path from 'path'
import cors from 'cors' import cors from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import morgan from 'morgan' import morgan from 'morgan'
@@ -116,6 +117,9 @@ app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
app.use('/api/v1/benefits', benefitRoutes) app.use('/api/v1/benefits', benefitRoutes)
app.use('/api/v1/esign', esignRoutes) app.use('/api/v1/esign', esignRoutes)
// 静态文件服务:上传的文件(入职文件、工会回执等)
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')))
app.use(errorHandler) app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动) // RAG 知识库自动初始化(异步,不阻塞启动)
+153 -2
View File
@@ -4,7 +4,10 @@ import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema' import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service' import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
import prisma from '../lib/prisma' import prisma from '../lib/prisma'
import { createEvidence } from '../services/evidence.service' import { createEvidence, appendEvidence } from '../services/evidence.service'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
const router = Router() const router = Router()
@@ -39,7 +42,11 @@ router.get('/checklist/:reason', authMiddleware, async (req: AuthRequest, res, n
} }
} }
const checklist = getChecklistForReason(req.params.reason, employee) // 获取组织所在城市,用于地区差异化合规检查(如北京通知工会程序)
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { city: true } })
const orgCity = org?.city || undefined
const checklist = getChecklistForReason(req.params.reason, employee, orgCity)
res.json({ success: true, data: checklist }) res.json({ success: true, data: checklist })
} catch (err) { } catch (err) {
next(err) next(err)
@@ -360,4 +367,148 @@ router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next)
} }
}) })
// ========== 工会回执上传(北京地区单方解除证据链) ==========
// 工会回执文件上传目录
const unionReceiptDir = path.join(process.cwd(), 'uploads', 'union-receipt')
if (!fs.existsSync(unionReceiptDir)) fs.mkdirSync(unionReceiptDir, { recursive: true })
const unionReceiptUpload = multer({
storage: multer.diskStorage({
destination: unionReceiptDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) cb(null, true)
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
},
})
/**
* 上传工会回执文件
* 北京地区单方解除劳动合同,工会收到通知后出具的书面回执扫描件
*/
router.post('/draft/:id/union-receipt/upload', authMiddleware, unionReceiptUpload.single('file'), async (req: AuthRequest, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
}
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
fs.unlinkSync(req.file.path)
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const fileUrl = `/uploads/union-receipt/${req.file.filename}`
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileSize: req.file.size } })
} catch (err) {
next(err)
}
})
/**
* 保存工会回执信息(文件URL + 回执编号 + 工会意见)到草稿,并追加到证据链
*/
router.post('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } = req.body
// 将工会回执信息保存到草稿的 checklistOverrides 中
const checklistOverrides: any = (record.checklistOverrides as any) || {}
checklistOverrides['union_receipt'] = {
checked: true,
overrideReason: '已收到工会书面回执',
receiptNo,
unionName,
receiptDate,
fileUrl,
fileName,
unionOpinion,
}
// 同步标记 notify_union 已完成
if (!checklistOverrides['notify_union']) {
checklistOverrides['notify_union'] = {
checked: true,
overrideReason: '已通知工会并收到回执',
}
}
await prisma.terminationRecord.update({
where: { id: record.id },
data: { checklistOverrides },
})
// 追加到证据链
await appendEvidence(
req.user!.orgId,
// 查找该解聘记录对应的证据链
(await prisma.evidenceChain.findFirst({
where: { orgId: req.user!.orgId, category: 'TERMINATION', refId: record.id },
}))?.id || '',
{
action: '工会书面回执已收到',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
}
).catch(() => {
// 证据链可能不存在(草稿阶段未创建),创建新的证据链
return createEvidence({
orgId: req.user!.orgId,
category: 'TERMINATION',
refId: record.id,
employeeId: record.employeeId,
events: [{
action: '工会书面回执已收到',
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent'] as string,
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
}],
createdBy: req.user!.id,
})
})
await auditLog(req, 'UNION_RECEIPT', 'TERMINATION_RECORD', record.id, { receiptNo, unionName, fileUrl })
res.json({ success: true, data: { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } })
} catch (err) {
next(err)
}
})
/**
* 获取工会回执信息
*/
router.get('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.terminationRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
}
const checklistOverrides: any = (record.checklistOverrides as any) || {}
const unionReceipt = checklistOverrides['union_receipt'] || null
res.json({ success: true, data: unionReceipt })
} catch (err) {
next(err)
}
})
export default router export default router
+7
View File
@@ -30,6 +30,13 @@ const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' }, { title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' }, { title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' }, { title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
// ===== 北京地区地方性指引 =====
{ title: '北京:规范用人单位单方解除劳动合同工作指引(试行)- 通知工会程序', content: '用人单位单方解除劳动合同的,应当提前五个工作日将理由书面通知本单位工会;尚未建立工会组织的,应当通知上一级工会。上一级工会,原则上是用人单位实际经营地的乡镇、街道、园区、开发区总工会。用人单位隶属区产业工会的,应通知其所属区产业工会。用人单位隶属市产业工会的,应通知其所属市产业工会中的上级工会。用人单位可联系所在地的乡镇、街道、园区、开发区总工会,或拨打12351职工服务热线,咨询本单位对应的上一级工会等事宜。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:通知工会函内容要求', content: '用人单位单方解除劳动合同书面通知工会时,通知文本应当写明劳动者基本情况(姓名、性别、年龄、身份证号、工作岗位、工作年限、劳动合同期限、联系方式),解除劳动合同所依据的基本事实,解除劳动合同援引的法律、法规、本单位规章制度的相关条款,以及用人单位的联系人和联系方式等内容。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:工会回执要求', content: '用人单位书面通知工会的送达方式为直接送达、邮寄送达等。用人单位工会收到通知后,应及时出具书面回执;上一级工会收到尚未建立工会组织的用人单位通知后,确定用人单位属于联系范围的,应出具书面回执,认为不属于的,应及时提醒用人单位。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:工会劳动法律监督提示函', content: '工会认为用人单位违反法律、法规和有关合同的,应当自收到用人单位书面通知五个工作日内,通过发放《工会劳动法律监督提示函》等方式提出意见建议。用人单位应当研究工会的意见,并将处理结果书面通知工会。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京:仲裁审查通知工会义务', content: '劳动人事争议仲裁委员会在审理解除劳动合同争议案件过程中,依法审查用人单位单方解除劳动合同时是否履行通知工会的义务,了解掌握工会提出的意见建议。未履行通知工会程序的可能被认定为违法解除。', source: '北京市协调劳动关系三方', category: '解除终止' },
{ title: '北京市实施《中华人民共和国工会法》办法', content: '北京市实施《中华人民共和国工会法》办法是北京市地方性法规,对工会组织建设、工会权利义务、工会经费等作出规定。用人单位单方解除劳动合同应当遵守该办法关于通知工会的规定。', source: '北京市实施工会法办法', category: '解除终止' },
] ]
let initialized = false let initialized = false
+23 -19
View File
@@ -16,7 +16,9 @@ export interface ChecklistItem {
suggestionType?: 'info' | 'warning' | 'required' suggestionType?: 'info' | 'warning' | 'required'
} }
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] { export function getChecklistForReason(reason: string, employee?: any, orgCity?: string): ChecklistItem[] {
// 北京地区单方解除须通知工会(依据《规范用人单位单方解除劳动合同工作指引》)
const isBeijing = !orgCity || orgCity === '北京' || orgCity === '北京市' || orgCity?.includes('北京')
switch (reason) { switch (reason) {
case 'NEGOTIATED': case 'NEGOTIATED':
return [ return [
@@ -33,14 +35,14 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
return [ return [
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null }, { key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null }, { key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
{ ...(isBeijing ? [{
key: 'notify_union', key: 'notify_union',
label: '是否提前5个工作日书面通知工会', label: '是否提前5个工作日书面通知工会',
autoChecked: null, autoChecked: null as any,
suggestion: '北京地区要求:单方解除劳动合同须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会(用人单位实际经营地的乡镇/街道/园区/开发区总工会)。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。', suggestion: '北京地区要求:单方解除劳动合同须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会(用人单位实际经营地的乡镇/街道/园区/开发区总工会)。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
suggestionType: 'required', suggestionType: 'required' as const,
}, }] : []),
{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null }, ...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null }, { key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
] ]
case 'NONFAULT': { case 'NONFAULT': {
@@ -94,18 +96,20 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
}) })
// 通知工会 — 北京地区单方解除必经程序 // 通知工会 — 北京地区单方解除必经程序
items.push({ if (isBeijing) {
key: 'notify_union', items.push({
label: '是否提前5个工作日书面通知工会', key: 'notify_union',
autoChecked: null, label: '是否提前5个工作日书面通知工会',
suggestion: '北京地区要求:单方解除须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。', autoChecked: null,
suggestionType: 'required', suggestion: '北京地区要求:单方解除须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
}) suggestionType: 'required',
items.push({ })
key: 'union_receipt', items.push({
label: '是否收到工会书面回执', key: 'union_receipt',
autoChecked: null, label: '是否收到工会书面回执',
}) autoChecked: null,
})
}
return items return items
} }
@@ -113,7 +117,7 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
return [ return [
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null }, { key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null }, { key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null }, ...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null }, { key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
{ {
key: 'compensation_paid', label: '是否支付经济补偿金', key: 'compensation_paid', label: '是否支付经济补偿金',
+12
View File
@@ -697,6 +697,18 @@ export const terminationApi = {
/** 批量预览 */ /** 批量预览 */
batchPreview: (items: Record<string, unknown>[]) => batchPreview: (items: Record<string, unknown>[]) =>
post('/termination/batch/preview', { items }), post('/termination/batch/preview', { items }),
/** 上传工会回执文件 */
uploadUnionReceipt: (draftId: string, file: File) => {
const formData = new FormData()
formData.append('file', file)
return post(`/termination/draft/${draftId}/union-receipt/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
},
/** 保存工会回执信息 */
saveUnionReceipt: (draftId: string, data: Record<string, unknown>) =>
post(`/termination/draft/${draftId}/union-receipt`, data).then(unwrap<any>()),
/** 获取工会回执信息 */
getUnionReceipt: (draftId: string) =>
get(`/termination/draft/${draftId}/union-receipt`).then(unwrap<any>()),
} }
// ========== 制度相关 ========== // ========== 制度相关 ==========
+133 -13
View File
@@ -8,7 +8,7 @@ import { Stepper } from '../components/ui/Stepper'
import { InlineAlert } from '../components/ui/InlineAlert' import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide' import PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
import { rosterApi, terminationApi, esignApi } from '../lib/api-services' import { rosterApi, terminationApi, esignApi, settingsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card' import Card from '../components/ui/Card'
import Button from '../components/ui/Button' import Button from '../components/ui/Button'
@@ -42,10 +42,9 @@ const REASONS = [
steps: [ steps: [
'第一步:收集并固化证据(违纪事实、制度依据、证人证言等)', '第一步:收集并固化证据(违纪事实、制度依据、证人证言等)',
'第二步:确认规章制度经过民主程序制定并已向员工公示(签收记录)', '第二步:确认规章制度经过民主程序制定并已向员工公示(签收记录)',
'第三步:提前5个工作日将解除理由书面通知工会(北京地区必经程序),工会提出意见后书面回复工会', '第三步:将解除理由通知工会,工会提出意见后书面回复工会',
'第四步:向员工送达《解除劳动合同通知书》,注明解除依据和事实', '第四步:向员工送达《解除劳动合同通知书》,注明解除依据和事实',
'第五步:办理工作交接、社保减员、档案转移', '第五步:办理工作交接、社保减员、档案转移',
'⚠ 北京地区:单方解除须提前5个工作日书面通知工会,未建立工会的通知上一级工会(经营地乡镇/街道/园区/开发区总工会)。可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
'⚠ 注意:证据不足或制度未公示可能导致违法解除,建议咨询律师', '⚠ 注意:证据不足或制度未公示可能导致违法解除,建议咨询律师',
], ],
}, },
@@ -59,11 +58,9 @@ const REASONS = [
'第一步(不胜任):进行绩效考核,确认不胜任事实', '第一步(不胜任):进行绩效考核,确认不胜任事实',
'第二步(医疗期满):另行安排合适岗位,员工仍不能胜任', '第二步(医疗期满):另行安排合适岗位,员工仍不能胜任',
'第二步(不胜任):进行培训或调岗,再次考核仍不胜任', '第二步(不胜任):进行培训或调岗,再次考核仍不胜任',
'第三步:提前5个工作日书面通知工会(北京地区必经程序),收到工会回执后再解除', '第三步:提前30天书面通知员工,或额外支付1个月代通知金',
'第四步:提前30天书面通知员工,或额外支付1个月代通知金', '第四步:支付经济补偿金(N',
'第五步:支付经济补偿金(N', '第五步:出具解除证明,办理交接、社保减员',
'第六步:出具解除证明,办理交接、社保减员',
'⚠ 北京地区:单方解除须提前5个工作日书面通知工会,可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
], ],
}, },
{ {
@@ -74,13 +71,11 @@ const REASONS = [
steps: [ steps: [
'第一步:确认符合法定裁员情形,准备相关证明材料', '第一步:确认符合法定裁员情形,准备相关证明材料',
'第二步:提前30天向工会或全体职工说明情况(书面会议记录)', '第二步:提前30天向工会或全体职工说明情况(书面会议记录)',
'第三步:听取工会或职工意见,形成意见处理方案,收到工会书面回执', '第三步:听取工会或职工意见,形成意见处理方案',
'第四步:向当地劳动行政部门报告裁员方案', '第四步:向当地劳动行政部门报告裁员方案',
'第五步:确定裁员名单(优先留用法定保护人员)', '第五步:确定裁员名单(优先留用法定保护人员)',
'第六步:提前5个工作日书面通知工会拟解除名单(北京地区必经程序', '第六步:向员工送达解除通知,支付经济补偿金(N',
'第七步:向员工送达解除通知,支付经济补偿金(N)', '第七步:办理交接、社保减员、档案转移',
'第八步:办理交接、社保减员、档案转移',
'⚠ 北京地区:裁员属单方解除,须提前5个工作日书面通知工会,可在「文本模板库」使用《拟解除劳动合同通知工会函》模板',
], ],
}, },
{ {
@@ -176,6 +171,14 @@ export default function Termination() {
const [employeeId, setEmployeeId] = useState('') const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('') const [terminationDate, setTerminationDate] = useState('')
// 获取组织所在城市,用于地区差异化合规提示(如北京通知工会程序)
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: () => settingsApi.org(),
staleTime: 300000,
})
const isBeijing = !orgData?.city || orgData?.city === '北京' || orgData?.city === '北京市' || (orgData?.city as string)?.includes('北京')
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转) // 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
useEffect(() => { useEffect(() => {
const urlEmployeeId = searchParams.get('employeeId') const urlEmployeeId = searchParams.get('employeeId')
@@ -197,6 +200,16 @@ export default function Termination() {
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([]) const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS) const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({}) const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
/** 工会回执上传(北京地区单方解除证据链) */
const [unionReceipt, setUnionReceipt] = useState<{
receiptNo: string
unionName: string
receiptDate: string
fileUrl: string
fileName: string
unionOpinion: string
} | null>(null)
const [uploadingReceipt, setUploadingReceipt] = useState(false)
const [editingCompField, setEditingCompField] = useState<string | null>(null) const [editingCompField, setEditingCompField] = useState<string | null>(null)
const [editCompValue, setEditCompValue] = useState<number>(0) const [editCompValue, setEditCompValue] = useState<number>(0)
const [editCompReason, setEditCompReason] = useState('') const [editCompReason, setEditCompReason] = useState('')
@@ -1377,6 +1390,17 @@ ${items}
{r.steps.map((s, i) => ( {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> <li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
))} ))}
{/* 北京地区单方解除须通知工会 */}
{isBeijing && ['FAULT', 'NONFAULT', 'LAYOFF'].includes(r.value) && (
<>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
5///使
</li>
<li className="text-xs leading-relaxed text-amber-600 font-medium">
</li>
</>
)}
</ol> </ol>
</div> </div>
</div> </div>
@@ -1534,6 +1558,101 @@ ${items}
</div> </div>
) )
})} })}
{/* 工会回执上传区域(北京地区单方解除,checklist 包含 union_receipt 项时显示) */}
{checklistItems?.some(item => item.key === 'union_receipt') && checklist['notify_union'] && (
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
<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" />
</div>
<div className="text-xs text-gray-500">
稿
</div>
{unionReceipt && unionReceipt.fileUrl ? (
/* 已上传回执展示 */
<div className="space-y-2 bg-white rounded-md p-2.5 border">
<div className="flex items-center gap-2 text-xs">
<CheckCircle className="w-4 h-4 text-safe" />
<span className="font-medium"></span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>{unionReceipt.receiptNo || '—'}</div>
<div>{unionReceipt.unionName || '—'}</div>
<div>{unionReceipt.receiptDate || '—'}</div>
<div><a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{unionReceipt.fileName}</a></div>
</div>
{unionReceipt.unionOpinion && (
<div className="text-xs text-gray-600">{unionReceipt.unionOpinion}</div>
)}
<Button size="sm" variant="secondary" onClick={() => setUnionReceipt(null)}></Button>
</div>
) : (
/* 上传表单 */
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<Label></Label>
<Input value={unionReceipt?.receiptNo || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptNo: e.target.value }))} placeholder="如:2026第001号" />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionName || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionName: e.target.value }))} placeholder="如:XX街道总工会" />
</div>
<div>
<Label></Label>
<Input type="date" value={unionReceipt?.receiptDate || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptDate: e.target.value }))} />
</div>
<div>
<Label></Label>
<Input value={unionReceipt?.unionOpinion || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionOpinion: e.target.value }))} placeholder="如有意见建议请填写" />
</div>
</div>
<div>
<Label></Label>
<input
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file || !draftId) return
setUploadingReceipt(true)
try {
const res = await terminationApi.uploadUnionReceipt(draftId, file)
setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), fileUrl: res.fileUrl, fileName: res.fileName }))
toast.success('文件上传成功')
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '上传失败')
} finally {
setUploadingReceipt(false)
}
}}
className="block w-full text-xs text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-white hover:file:bg-primary/90"
/>
{uploadingReceipt && <div className="text-xs text-gray-400 mt-1">...</div>}
</div>
{draftId && unionReceipt?.fileUrl && (
<Button
size="sm"
onClick={async () => {
try {
await terminationApi.saveUnionReceipt(draftId, unionReceipt)
toast.success('工会回执已保存到证据链')
setChecklist({ ...checklist, union_receipt: true })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '保存失败')
}
}}
></Button>
)}
{!draftId && (
<div className="text-xs text-amber-600">稿</div>
)}
</div>
)}
</div>
)}
</div> </div>
)} )}
@@ -1896,6 +2015,7 @@ ${items}
<div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}> <div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}>
{checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'} {checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'}
{checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''} {checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''}
{unionReceipt?.fileUrl && <a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="ml-1 text-primary hover:underline"></a>}
</div> </div>
)} )}
</div> </div>