feat: 社保公积金版本管理 + 员工基数调整 + 加班费计算优化 + UI组件改进
- SocialInsuranceConfig 版本化(effectiveFrom/effectiveTo/isCurrent/adjustmentDone) - 社保配置版本管理接口(列表/新建/按月获取/当前版本) - 员工基数调整:预览全部在职员工、上年月均工资计算建议基数、可编辑表格、确认后批量保存 - payroll.service 按批次月份匹配对应版本社保配置 - risk.service / seed.ts 同步更新 - 前端社保tab重构为版本管理+调整+试算 - 加班费计算三步流程、CSV导入、批次导入 - UI组件、Dashboard、合同、薪酬等页面优化
This commit is contained in:
@@ -128,8 +128,9 @@ model Organization {
|
||||
salaryChangeRecords SalaryChangeRecord[]
|
||||
onboardingLinks OnboardingLink[]
|
||||
confirmLinks ContractConfirmLink[]
|
||||
socialInsuranceConfig SocialInsuranceConfig?
|
||||
socialInsuranceConfig SocialInsuranceConfig[]
|
||||
notificationSetting NotificationSetting?
|
||||
overtimeConfig OvertimeConfig?
|
||||
notificationLogs NotificationLog[]
|
||||
employeeAttachments EmployeeAttachment[]
|
||||
disciplinaryRecords DisciplinaryRecord[]
|
||||
@@ -236,6 +237,7 @@ model OvertimeRecord {
|
||||
weekendPay Float @default(0)
|
||||
holidayPay Float @default(0)
|
||||
totalPay Float @default(0)
|
||||
batchId String? // 关联的发薪批次(加入后锁定,不可重复加入)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, month])
|
||||
@@ -298,7 +300,7 @@ model AuditLog {
|
||||
|
||||
model SocialInsuranceConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京")
|
||||
pensionOrg Float @default(16) // 养老保险 企业比例 %
|
||||
@@ -313,7 +315,16 @@ model SocialInsuranceConfig {
|
||||
housingEmp Float @default(12) // 公积金 个人比例 %
|
||||
baseMin Float @default(6326) // 缴费基数下限
|
||||
baseMax Float @default(33891) // 缴费基数上限
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||
adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, effectiveFrom])
|
||||
@@index([orgId, isCurrent])
|
||||
}
|
||||
|
||||
model NotificationSetting {
|
||||
@@ -336,6 +347,18 @@ model NotificationSetting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model OvertimeConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
weekdayRate Float @default(1.5) // 工作日加班倍率
|
||||
weekendRate Float @default(2.0) // 休息日加班倍率
|
||||
holidayRate Float @default(3.0) // 法定节假日加班倍率
|
||||
monthlyDays Float @default(21.75) // 月计薪天数
|
||||
dailyHours Float @default(8) // 每日工时
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
|
||||
const EID = 'cmrx61v6d001oqqcwb2pu2tih'
|
||||
const ORGID = 'cmrx61v3l0000qqcwo3dr3h95'
|
||||
const UID = 'cmrx61v5u0002qqcwqf4vlyth'
|
||||
|
||||
async function main() {
|
||||
// 加班记录
|
||||
const otMonths = [
|
||||
{ month: '2025-03', wh: 8, weh: 4, hh: 0, wp: 600, wep: 600, hp: 0, pay: 1200 },
|
||||
{ month: '2025-06', wh: 12, weh: 8, hh: 0, wp: 1200, wep: 1200, hp: 0, pay: 2400 },
|
||||
{ month: '2025-09', wh: 6, weh: 0, hh: 8, wp: 600, wep: 0, hp: 1200, pay: 1800 },
|
||||
]
|
||||
for (const o of otMonths) {
|
||||
const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } })
|
||||
if (!existing) {
|
||||
await prisma.overtimeRecord.create({ data: { orgId: ORGID, employeeId: EID, month: o.month, weekdayHours: o.wh, weekendHours: o.weh, holidayHours: o.hh, weekdayPay: o.wp, weekendPay: o.wep, holidayPay: o.hp, totalPay: o.pay } })
|
||||
}
|
||||
}
|
||||
console.log('加班记录: 完成')
|
||||
|
||||
// 违纪记录
|
||||
const discRecords = [
|
||||
{ violationDate: new Date('2025-05-12'), violationType: 'LATE', description: '月度迟到超过5次,影响团队考勤', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '口头警告并谈话', employeeAck: true, ackDate: new Date('2025-05-13'), ackMethod: 'SIGN', witness: '王强' },
|
||||
{ violationDate: new Date('2025-09-20'), violationType: 'ABSENT', description: '未经请假擅自旷工1天', severity: 'SERIOUS', action: 'DEDUCTION', actionDetail: '扣款200元', employeeAck: true, ackDate: new Date('2025-09-21'), ackMethod: 'SIGN', witness: '王强' },
|
||||
]
|
||||
for (const d of discRecords) {
|
||||
const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } })
|
||||
if (!existing) {
|
||||
await prisma.disciplinaryRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...d } })
|
||||
}
|
||||
}
|
||||
console.log('违纪记录: 完成')
|
||||
|
||||
// 考勤记录 - 最近10个工作日
|
||||
const attendance = [
|
||||
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-11', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0 },
|
||||
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-18', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0 },
|
||||
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30 },
|
||||
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0 },
|
||||
]
|
||||
for (const a of attendance) {
|
||||
const existing = await prisma.attendanceRecord.findUnique({ where: { employeeId_date: { employeeId: EID, date: new Date(a.date) } } })
|
||||
if (!existing) {
|
||||
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: '09:00', checkOutTime: '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: 8, overtimeHours: 0 } })
|
||||
}
|
||||
}
|
||||
console.log('考勤记录: 完成')
|
||||
|
||||
// 培训签收记录
|
||||
const trainings = [
|
||||
{ trainingDate: new Date('2025-03-15'), topic: '《员工手册》培训', content: '公司规章制度、考勤制度、奖惩条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-03-15'), remark: '新员工入职培训' },
|
||||
{ trainingDate: new Date('2025-06-20'), topic: '销售技巧与合规培训', content: '销售话术规范、客户信息保护、合同签订注意事项', trainer: '王强', duration: 4, ackStatus: 'SIGNED', ackDate: new Date('2025-06-20') },
|
||||
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
|
||||
]
|
||||
for (const t of trainings) {
|
||||
const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } })
|
||||
if (!existing) {
|
||||
await prisma.trainingRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...t } })
|
||||
}
|
||||
}
|
||||
console.log('培训记录: 完成')
|
||||
|
||||
// 绩效记录
|
||||
const performances = [
|
||||
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
|
||||
{ period: '2025-Q2', score: 75, grade: 'B', result: 'QUALIFIED', summary: '业绩略有下滑,新客户开发不足,团队协作有待加强', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-07-08'), reviewer: '王强' },
|
||||
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
|
||||
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升', improvementPlan: '', employeeAck: false, reviewer: '王强' },
|
||||
]
|
||||
for (const p of performances) {
|
||||
const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } })
|
||||
if (!existing) {
|
||||
await prisma.performanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...p } })
|
||||
}
|
||||
}
|
||||
console.log('绩效记录: 完成')
|
||||
|
||||
// 附件
|
||||
const attachments = [
|
||||
{ fileName: '吴芳身份证扫描件.pdf', fileType: 'ID_CARD', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 102400 },
|
||||
{ fileName: '吴芳银行卡复印件.jpg', fileType: 'BANK_CARD', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 51200 },
|
||||
{ fileName: '吴芳劳动合同扫描件.pdf', fileType: 'CONTRACT_SCAN', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 204800 },
|
||||
{ fileName: '吴芳学历证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 81920 },
|
||||
]
|
||||
for (const a of attachments) {
|
||||
const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } })
|
||||
if (!existing) {
|
||||
await prisma.employeeAttachment.create({ data: { ...a, orgId: ORGID, employeeId: EID, uploadedBy: UID } })
|
||||
}
|
||||
}
|
||||
console.log('附件: 完成')
|
||||
|
||||
// 验证
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: EID },
|
||||
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true }
|
||||
})
|
||||
if (emp) {
|
||||
console.log('--- 吴芳完整档案数据统计 ---')
|
||||
console.log('contracts:', emp.contracts.length)
|
||||
console.log('payslips:', emp.payslips.length)
|
||||
console.log('overtimeRecords:', emp.overtimeRecords.length)
|
||||
console.log('disciplinaryRecords:', emp.disciplinaryRecords.length)
|
||||
console.log('attendanceRecords:', emp.attendanceRecords.length)
|
||||
console.log('trainingRecords:', emp.trainingRecords.length)
|
||||
console.log('performanceRecords:', emp.performanceRecords.length)
|
||||
console.log('terminations:', emp.terminations.length)
|
||||
console.log('attachments:', emp.attachments.length)
|
||||
}
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
+13
-10
@@ -32,15 +32,15 @@ function calcTax(taxableIncome: number): number {
|
||||
|
||||
// 9名员工完整数据
|
||||
const EMPLOYEES = [
|
||||
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' },
|
||||
{ name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true },
|
||||
{ name: '王强', gender: '男', dept: '销售部', phone: '13900000003', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' },
|
||||
{ name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' },
|
||||
{ name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' },
|
||||
{ name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' },
|
||||
{ name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' },
|
||||
{ name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' },
|
||||
{ name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' },
|
||||
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', idCard: '310101199001011234', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' },
|
||||
{ name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', idCard: '310102199203052345', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true },
|
||||
{ name: '王强', gender: '男', dept: '销售部', phone: '13900000003', idCard: '310103198812103456', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' },
|
||||
{ name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', idCard: '310104199506154567', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' },
|
||||
{ name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', idCard: '310105199907205678', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' },
|
||||
{ name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', idCard: '310106198504016789', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' },
|
||||
{ name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', idCard: '310107199311157890', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' },
|
||||
{ name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', idCard: '310108199008018901', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' },
|
||||
{ name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', idCard: '310109199702159012', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
@@ -113,6 +113,8 @@ async function main() {
|
||||
housingEmp: 7,
|
||||
baseMin: 7384,
|
||||
baseMax: 36921,
|
||||
effectiveFrom: '2025-07',
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
console.log('社保配置已创建')
|
||||
@@ -164,6 +166,7 @@ async function main() {
|
||||
hireDate: new Date(e.hireDate),
|
||||
monthlySalary: encrypt(String(e.salary)),
|
||||
phone: e.phone,
|
||||
idCardNumber: encrypt(e.idCard),
|
||||
gender: e.gender,
|
||||
socialInsBase: e.socialBase,
|
||||
housingFundBase: e.housingBase,
|
||||
@@ -204,7 +207,7 @@ async function main() {
|
||||
|
||||
// 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据
|
||||
console.log('\n生成 1-6 月历史工资条...')
|
||||
const socialConfig = await prisma.socialInsuranceConfig.findUnique({ where: { orgId: org.id } })
|
||||
const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
|
||||
const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } })
|
||||
const months = ['2026-01', '2026-02', '2026-03', '2026-04', '2026-05', '2026-06']
|
||||
|
||||
|
||||
@@ -283,13 +283,50 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班数据 ==========
|
||||
// ========== 加班费计算规则配置 ==========
|
||||
|
||||
const overtimeConfigSchema = z.object({
|
||||
weekdayRate: z.number().min(1).default(1.5),
|
||||
weekendRate: z.number().min(1).default(2.0),
|
||||
holidayRate: z.number().min(1).default(3.0),
|
||||
monthlyDays: z.number().min(1).default(21.75),
|
||||
dailyHours: z.number().min(1).default(8),
|
||||
})
|
||||
|
||||
// 获取加班费计算规则
|
||||
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
|
||||
if (!config) {
|
||||
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存加班费计算规则
|
||||
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeConfigSchema.parse(req.body)
|
||||
const config = await prisma.overtimeConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班工时 ==========
|
||||
|
||||
const batchOvertimeSchema = z.array(
|
||||
z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
@@ -302,19 +339,13 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
const results: any[] = []
|
||||
|
||||
for (const data of items) {
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
@@ -323,7 +354,6 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
})
|
||||
results.push(record)
|
||||
@@ -335,4 +365,80 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批次导入加班费 ==========
|
||||
|
||||
router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
|
||||
|
||||
// 获取加班费计算规则
|
||||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
|
||||
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
|
||||
const cfg = config
|
||||
|
||||
// 获取该月未关联批次的加班记录
|
||||
const overtimeRecords = await prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month: batch.month, batchId: null },
|
||||
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
|
||||
})
|
||||
|
||||
if (overtimeRecords.length === 0) {
|
||||
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
|
||||
}
|
||||
|
||||
const results: any[] = []
|
||||
for (const ot of overtimeRecords) {
|
||||
// 获取员工月工资
|
||||
let monthlyWage = 0
|
||||
try {
|
||||
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
|
||||
} catch {
|
||||
monthlyWage = Number(ot.employee.monthlySalary) || 0
|
||||
}
|
||||
if (!monthlyWage) continue
|
||||
|
||||
// 根据规则计算加班费
|
||||
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
|
||||
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
|
||||
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
|
||||
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
// 更新加班记录:计算金额并锁定到批次
|
||||
await prisma.overtimeRecord.update({
|
||||
where: { id: ot.id },
|
||||
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
|
||||
})
|
||||
|
||||
// 更新批次条目的加班费
|
||||
const entry = await prisma.batchEntry.findUnique({
|
||||
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
|
||||
})
|
||||
if (entry) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { overtimePay: totalPay },
|
||||
})
|
||||
// 重新计算条目
|
||||
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { totalPay: newTotalPay },
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { imported: results.length, details: results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -125,6 +125,22 @@ router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextF
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可复制的归档批次列表
|
||||
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: { orgId, status: 'ARCHIVED' },
|
||||
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
|
||||
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
|
||||
take: 20,
|
||||
})
|
||||
res.json({ success: true, data: batches })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取批次列表
|
||||
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -167,13 +183,15 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月已有批次数
|
||||
@@ -189,34 +207,55 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const monthStart = new Date(`${month}-01`)
|
||||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
let employees: any[]
|
||||
if (type === 'TERMINATION') {
|
||||
// 离职结算批次:本月离职员工
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
} else {
|
||||
// 常规/奖金批次:在职 + 本月离职
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
OR: [
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
// 获取上月发薪数据作为默认值
|
||||
// 获取上月发薪数据
|
||||
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
|
||||
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}`
|
||||
|
||||
// 根据模式确定员工列表和数据来源
|
||||
let employees: any[] = []
|
||||
let sourceEntries: any[] | null = null
|
||||
|
||||
if (mode === 'blank_all') {
|
||||
// 全空白:不拉入员工
|
||||
employees = []
|
||||
} else if (mode === 'copy_batch' && sourceBatchId) {
|
||||
// 复制指定批次:从源批次复制条目
|
||||
const sourceBatch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
|
||||
include: { entries: true },
|
||||
})
|
||||
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
|
||||
sourceEntries = sourceBatch.entries
|
||||
// 提取员工 ID,后续按此创建条目
|
||||
const employeeIds = sourceEntries.map(e => e.employeeId)
|
||||
employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds }, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
} else {
|
||||
// copy_last 或 blank_employees:拉入员工
|
||||
if (type === 'TERMINATION') {
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
} else {
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
OR: [
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 创建批次
|
||||
const batch = await prisma.payrollBatch.create({
|
||||
data: {
|
||||
@@ -234,36 +273,52 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
// 创建批次条目
|
||||
const entries: any[] = []
|
||||
for (const emp of employees) {
|
||||
// 获取上次发薪数据
|
||||
const prevPayslip = await prisma.payslip.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
|
||||
})
|
||||
|
||||
// 获取加班费
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
// 基本工资
|
||||
let baseSalary = 0
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
let overtimePay = 0
|
||||
let allowance = 0
|
||||
let deduction = 0
|
||||
let bonus = 0
|
||||
|
||||
// 如果有上次发薪数据,带入
|
||||
if (prevPayslip) {
|
||||
baseSalary = prevPayslip.baseSalary
|
||||
}
|
||||
if (mode === 'copy_batch' && sourceEntries) {
|
||||
// 复制指定批次:从源条目复制数据
|
||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||
if (srcEntry) {
|
||||
baseSalary = srcEntry.baseSalary
|
||||
overtimePay = srcEntry.overtimePay
|
||||
allowance = srcEntry.allowance
|
||||
deduction = srcEntry.deduction
|
||||
bonus = srcEntry.bonus
|
||||
}
|
||||
} else if (mode === 'copy_last') {
|
||||
// 复制上月:从上月工资条复制
|
||||
const prevPayslip = await prisma.payslip.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
|
||||
})
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const allowance = prevPayslip?.allowance || 0
|
||||
const deduction = prevPayslip?.deduction || 0
|
||||
const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认0,手动填写
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
if (prevPayslip) baseSalary = prevPayslip.baseSalary
|
||||
overtimePay = overtime?.totalPay || 0
|
||||
allowance = prevPayslip?.allowance || 0
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
}
|
||||
// blank_employees 和 blank_all: 所有金额默认 0
|
||||
|
||||
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
|
||||
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
})
|
||||
|
||||
// 计算社保、个税等
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
|
||||
// 风险提示
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
|
||||
@@ -322,13 +377,17 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑批次条目(计算依据项)
|
||||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||||
const updateEntrySchema = z.object({
|
||||
baseSalary: z.number().min(0).optional(),
|
||||
overtimePay: z.number().min(0).optional(),
|
||||
allowance: z.number().min(0).optional(),
|
||||
deduction: z.number().min(0).optional(),
|
||||
bonus: z.number().min(0).optional(),
|
||||
socialEmp: z.number().min(0).optional(),
|
||||
socialOrg: z.number().min(0).optional(),
|
||||
housingEmp: z.number().min(0).optional(),
|
||||
housingOrg: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@@ -355,8 +414,16 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
bonus: data.bonus ?? entry.bonus,
|
||||
}
|
||||
|
||||
// 构建社保覆盖参数(如果请求中包含社保字段)
|
||||
const overrideSocial: any = {}
|
||||
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
|
||||
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
|
||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
@@ -368,14 +435,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
|
||||
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
|
||||
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
|
||||
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
|
||||
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
|
||||
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
|
||||
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
|
||||
}), { totalPay: 0, totalNetPay: 0, totalTax: 0 })
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
@@ -467,6 +542,25 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest
|
||||
}
|
||||
})
|
||||
|
||||
// 删除批次(仅限草稿状态)
|
||||
router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } })
|
||||
|
||||
await prisma.batchEntry.deleteMany({ where: { batchId } })
|
||||
await prisma.payrollBatch.delete({ where: { id: batchId } })
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 归档批次
|
||||
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -37,18 +38,39 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = employees.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract: e.contracts[0] || null,
|
||||
counts: e._count,
|
||||
}))
|
||||
const result = employees.map((e) => {
|
||||
const latestContract = e.contracts[0] || null
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: e.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
return {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
counts: e._count,
|
||||
}
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -75,10 +97,15 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const { monthlySalary, ...rest } = employee
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
res.json({
|
||||
success: true,
|
||||
data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) },
|
||||
data: {
|
||||
...rest,
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取社保配置
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新社保配置
|
||||
const configSchema = z.object({
|
||||
const configFields = {
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
@@ -38,35 +21,269 @@ const configSchema = z.object({
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
})
|
||||
}
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
// 获取当前生效版本
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = configSchema.parse(req.body)
|
||||
const config = await prisma.socialInsuranceConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算
|
||||
// 获取所有版本列表
|
||||
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const versions = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 按月份获取适用版本
|
||||
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.params
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
// 回退到当前版本
|
||||
const current = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
})
|
||||
return res.json({ success: true, data: current })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建版本(年度调基/比例变更)
|
||||
const createVersionSchema = z.object({
|
||||
...configFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查同一生效月份是否已有版本
|
||||
const existing = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
|
||||
}
|
||||
|
||||
// 将之前当前版本标记为失效
|
||||
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
const version = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
|
||||
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
month: { gte: lastYearStart, lte: lastYearEnd },
|
||||
},
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
// 按员工汇总上年月均工资
|
||||
const avgSalaryMap = new Map<string, number>()
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
for (const [empId, pays] of empPayslipMap) {
|
||||
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
|
||||
avgSalaryMap.set(empId, avg)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldSocialBase = emp.socialInsBase ?? monthlyWage
|
||||
const oldHousingBase = emp.housingFundBase ?? monthlyWage
|
||||
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
|
||||
// 建议基数 = 上年月均工资按上下限裁剪
|
||||
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldSocialBase,
|
||||
oldHousingBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedSocialBase,
|
||||
suggestedHousingBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行员工基数调整(接收用户编辑后的数据)
|
||||
const adjustApplySchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newSocialBase: z.number(),
|
||||
newHousingBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const { items } = adjustApplySchema.parse(req.body)
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
// 裁剪到上下限范围内
|
||||
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
|
||||
const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax)
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: {
|
||||
socialInsBase: socialBase,
|
||||
housingFundBase: housingBase,
|
||||
},
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算(使用当前版本或指定月份版本)
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base } = calcSchema.parse(req.body)
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
const { base, month } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
if (month) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
@@ -93,6 +310,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
items: [
|
||||
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
|
||||
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
|
||||
|
||||
@@ -29,6 +29,11 @@ export const updateEmployeeSchema = z.object({
|
||||
monthlySalary: z.string().min(1).optional(),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
bankName: z.string().max(50).optional(),
|
||||
bankAccount: z.string().max(30).optional(),
|
||||
emergencyContact: z.string().max(30).optional(),
|
||||
emergencyPhone: z.string().max(20).optional(),
|
||||
address: z.string().max(200).optional(),
|
||||
isPregnant: z.boolean().optional(),
|
||||
isInMedicalPeriod: z.boolean().optional(),
|
||||
isWorkInjured: z.boolean().optional(),
|
||||
|
||||
@@ -14,11 +14,12 @@ export function getContractStatus(contract: {
|
||||
hireDate: Date
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
if (days > 365) {
|
||||
return { status: 'unsigned_over_year', statusText: '已视为无固定期限', riskLevel: 'high' }
|
||||
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
|
||||
} else if (days > 30) {
|
||||
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
|
||||
}
|
||||
@@ -28,14 +29,14 @@ export function getContractStatus(contract: {
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
return { status: 'expired', statusText: '已到期未续签', riskLevel: 'high' }
|
||||
return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
|
||||
} else if (daysToExpire <= 30) {
|
||||
return { status: 'expiring', statusText: `即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
|
||||
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
return { status: 'active', statusText: '正常', riskLevel: 'safe' }
|
||||
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
return { status: 'unfixed', statusText: '无固定期限', riskLevel: 'safe' }
|
||||
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
|
||||
@@ -233,6 +234,11 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
}
|
||||
if (data.gender !== undefined) updateData.gender = data.gender
|
||||
if (data.phone !== undefined) updateData.phone = data.phone
|
||||
if (data.bankName !== undefined) updateData.bankName = data.bankName
|
||||
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
|
||||
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
|
||||
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
|
||||
if (data.address !== undefined) updateData.address = data.address
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
@@ -325,6 +331,10 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||
attachmentUrl: data.attachmentUrl || null,
|
||||
electronicContractNo: data.electronicContractNo || null,
|
||||
electronicContractUrl: data.electronicContractUrl || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -115,10 +115,18 @@ export async function calcBatchEntry(
|
||||
month: string,
|
||||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
|
||||
batchType: string = 'REGULAR',
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||||
) {
|
||||
const [employee, socialConfig] = await Promise.all([
|
||||
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
@@ -127,13 +135,25 @@ export async function calcBatchEntry(
|
||||
const housingBase = employee.housingFundBase || inputs.baseSalary
|
||||
|
||||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||||
if (socialConfig) {
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
const housing = calcHousingFund(housingBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
socialOrg = social.socialOrg
|
||||
housingEmp = housing.housingEmp
|
||||
housingOrg = housing.housingOrg
|
||||
|
||||
// 年终奖/奖金批次:不扣社保公积金
|
||||
if (batchType !== 'BONUS' && !options?.skipSocial) {
|
||||
if (socialConfig) {
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
const housing = calcHousingFund(housingBase, socialConfig)
|
||||
socialEmp = social.socialEmp
|
||||
socialOrg = social.socialOrg
|
||||
housingEmp = housing.housingEmp
|
||||
housingOrg = housing.housingOrg
|
||||
}
|
||||
}
|
||||
|
||||
// 手动覆盖社保值
|
||||
if (options?.overrideSocial) {
|
||||
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
|
||||
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
|
||||
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
|
||||
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
||||
}
|
||||
|
||||
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
|
||||
|
||||
@@ -274,7 +274,7 @@ export async function getDashboardData(orgId: string) {
|
||||
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
|
||||
}),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function Button({ variant = 'primary', size = 'md', className, ch
|
||||
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
|
||||
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
|
||||
'bg-danger text-white hover:bg-red-700': variant === 'danger',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-sm': size === 'md',
|
||||
'px-6 py-3 text-base': size === 'lg',
|
||||
'px-2.5 py-1 text-xs': size === 'sm',
|
||||
'px-3 py-1.5 text-xs': size === 'md',
|
||||
'px-5 py-2 text-sm': size === 'lg',
|
||||
},
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import clsx from 'clsx'
|
||||
|
||||
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
|
||||
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-3', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,12 +12,12 @@ interface EmptyStateProps {
|
||||
|
||||
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-gray-300 mb-4">
|
||||
{icon || <Inbox className="w-12 h-12" />}
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="text-gray-300 mb-3">
|
||||
{icon || <Inbox className="w-10 h-10" />}
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1">{title}</h3>
|
||||
{description && <p className="text-xs text-gray-500 mb-3">{description}</p>}
|
||||
{actionLabel && onAction && (
|
||||
<Button onClick={onAction}>{actionLabel}</Button>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputE
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm',
|
||||
'w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,7 +22,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
|
||||
<select
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm bg-white',
|
||||
'w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs bg-white',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -34,5 +34,5 @@ export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSel
|
||||
)
|
||||
|
||||
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
|
||||
return <label className={clsx('block text-xs font-medium text-gray-700 mb-0.5', className)}>{children}</label>
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ export default function Modal({ open, onClose, title, children, className }: Mod
|
||||
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900">{title}</h3>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+10
-4
@@ -6,16 +6,22 @@
|
||||
body {
|
||||
@apply bg-surface text-gray-900 antialiased;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply box-border;
|
||||
}
|
||||
|
||||
h1 { @apply text-base font-semibold; }
|
||||
h2 { @apply text-sm font-semibold; }
|
||||
h3 { @apply text-sm font-medium; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply inline-flex items-center justify-center px-3 py-1.5 rounded font-medium text-xs transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-primary text-white hover:bg-primary-dark;
|
||||
@@ -27,12 +33,12 @@
|
||||
@apply btn bg-danger text-white hover:bg-red-700;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-3;
|
||||
}
|
||||
.input {
|
||||
@apply w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm;
|
||||
@apply w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs;
|
||||
}
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
@apply block text-xs font-medium text-gray-700 mb-0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function AIAssistant() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">AI 合规顾问</h1>
|
||||
<h1 className="text-xs font-medium">AI 合规顾问</h1>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
@@ -40,7 +40,7 @@ export default function AIAssistant() {
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -95,7 +95,7 @@ function ChatTab() {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-sm whitespace-pre-wrap ${
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
@@ -111,7 +111,7 @@ function ChatTab() {
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => send(q)}
|
||||
className="px-3 py-1.5 text-sm rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
@@ -167,7 +167,7 @@ function PredictTab() {
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}>刷新预测</Button>
|
||||
@@ -204,7 +204,7 @@ function ReviewTab() {
|
||||
</div>
|
||||
<Label>粘贴合同条款文本</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[200px] resize-y"
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
|
||||
placeholder="粘贴劳动合同文本..."
|
||||
value={contractText}
|
||||
onChange={(e) => setContractText(e.target.value)}
|
||||
@@ -219,7 +219,7 @@ function ReviewTab() {
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">审查结果</h3>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@ function CaseTab() {
|
||||
</div>
|
||||
<Label>描述你的争议情形</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm min-h-[150px] resize-y"
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
|
||||
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
|
||||
value={scenario}
|
||||
onChange={(e) => setScenario(e.target.value)}
|
||||
@@ -269,7 +269,7 @@ function CaseTab() {
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">分析结果</h3>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -61,15 +61,15 @@ export default function Compensation() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">补偿计算</h1>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">补偿计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -150,7 +150,7 @@ function SeveranceCalculator() {
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>入职日期</Label>
|
||||
@@ -189,19 +189,19 @@ function SeveranceCalculator() {
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
<div className="text-sm text-gray-500">工作年限:<span className="text-gray-900">{result.years}年{result.remainingMonths}个月</span></div>
|
||||
<div className="text-xs text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
<div className="text-xs text-gray-500">工作年限:<span className="text-gray-900">{result.years}年{result.remainingMonths}个月</span></div>
|
||||
{result.noComp ? (
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
||||
{result.reasonNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||||
<div className="text-xs text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||||
{result.capped && (
|
||||
<div className="text-sm text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
<div className="text-xs text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">计算基数:<span className="text-gray-900">¥{fmt(result.wage)}/月</span></div>
|
||||
<div className="text-xs text-gray-500">计算基数:<span className="text-gray-900">¥{fmt(result.wage)}/月</span></div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
|
||||
@@ -211,7 +211,7 @@ function SeveranceCalculator() {
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)} × 2)</div>
|
||||
</>
|
||||
@@ -219,7 +219,7 @@ function SeveranceCalculator() {
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.reason}</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(result.totalPay)}</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{fmt(result.noticePay)}</span>}
|
||||
@@ -228,12 +228,12 @@ function SeveranceCalculator() {
|
||||
)}
|
||||
</div>
|
||||
{result.reasonNote && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-sm ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{result.reasonNote}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</span>
|
||||
</div>
|
||||
@@ -241,7 +241,7 @@ function SeveranceCalculator() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">填写信息后点击「计算」按钮</div>
|
||||
<div className="text-gray-400 text-xs">填写信息后点击「计算」按钮</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
@@ -303,7 +303,7 @@ function DoubleSalaryCalculator() {
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>月工资(元)</Label>
|
||||
@@ -333,24 +333,24 @@ function DoubleSalaryCalculator() {
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
<div className="text-sm text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||||
<div className="text-sm text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-sm text-gray-500">双倍工资截止:<span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-xs text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
<div className="text-xs text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||||
<div className="text-xs text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-xs text-gray-500">双倍工资截止:<span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">需赔</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{fmt(monthlyWage)})</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>法律规定:入职1个月没签合同,从第2个月起要付双倍工资,最多11个月</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm">请填写入职日期</div>
|
||||
<div className="text-gray-400 text-xs">请填写入职日期</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function Contracts() {
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
@@ -108,10 +108,19 @@ export default function Contracts() {
|
||||
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Signal level={emp.riskLevel} />
|
||||
<span>{emp.contractStatusText}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const tagStyles: Record<string, string> = {
|
||||
expired: 'bg-red-50 text-danger',
|
||||
unsigned_over_year: 'bg-red-50 text-danger',
|
||||
unsigned_over_30: 'bg-red-50 text-danger',
|
||||
unsigned: 'bg-yellow-50 text-yellow-700',
|
||||
expiring: 'bg-yellow-50 text-yellow-700',
|
||||
active: 'bg-green-50 text-safe',
|
||||
unfixed: 'bg-blue-50 text-blue-700',
|
||||
}
|
||||
const style = tagStyles[emp.contractStatus] || 'bg-gray-100 text-gray-600'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{emp.contractStatusText}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex gap-1">
|
||||
@@ -135,7 +144,7 @@ export default function Contracts() {
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>上一页</Button>
|
||||
<span className="text-sm text-gray-500">{page} / {data.totalPages}</span>
|
||||
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -221,7 +230,7 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
<Modal open={open} onClose={onClose} title="添加员工">
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{error.response?.data?.error?.message || '操作失败'}
|
||||
</div>
|
||||
)}
|
||||
@@ -264,15 +273,15 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
|
||||
{/* 特殊状态 */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||||
孕期/哺乳期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||||
医疗期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-sm">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||||
工伤
|
||||
</label>
|
||||
@@ -392,7 +401,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white h-full overflow-y-auto shadow-xl">
|
||||
<div className="relative w-full max-w-2xl bg-white h-full overflow-y-auto shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-medium text-gray-900">员工详情</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
@@ -406,9 +415,9 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{emp.name}</h2>
|
||||
<span className="text-sm text-gray-500">{emp.department}</span>
|
||||
<span className="text-xs text-gray-500">{emp.department}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span className="text-gray-400">入职日期:</span>{emp.hireDate?.slice(0, 10)}</div>
|
||||
<div><span className="text-gray-400">性别:</span>{emp.gender || '-'}</div>
|
||||
<div><span className="text-gray-400">手机:</span>{emp.phone || '-'}</div>
|
||||
@@ -425,13 +434,16 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
|
||||
{emp.contracts && emp.contracts.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">合同信息</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<h3 className="font-medium text-xs mb-2">合同信息</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
{emp.contracts.map((c: any) => (
|
||||
<div key={c.id} className="bg-gray-50 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Signal level={c.riskLevel || 'safe'} />
|
||||
<span>{c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'}</span>
|
||||
{(() => {
|
||||
const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'
|
||||
const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${typeStyle}`}>{typeLabel}</span>
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
|
||||
@@ -446,13 +458,13 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-sm flex items-center gap-1">
|
||||
<h3 className="font-medium text-xs flex items-center gap-1">
|
||||
<Paperclip className="w-4 h-4" /> 附件管理
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-sm">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
|
||||
<option value="ID_CARD">身份证</option>
|
||||
<option value="BANK_CARD">银行卡</option>
|
||||
<option value="CONTRACT_SCAN">合同扫描件</option>
|
||||
@@ -478,7 +490,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
{attachments && attachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((att: any) => (
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-sm">
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
@@ -498,7 +510,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm text-center py-4">暂无附件</div>
|
||||
<div className="text-gray-400 text-xs text-center py-4">暂无附件</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,11 +103,11 @@ export default function Dashboard() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">{data.greeting}</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
<h1 className="text-xs font-medium">{data.greeting}</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
@@ -123,7 +123,7 @@ export default function Dashboard() {
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -141,16 +141,16 @@ export default function Dashboard() {
|
||||
|
||||
{/* 概览 Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<Card key={stat.label} className="flex items-center gap-3">
|
||||
<Icon className={`w-8 h-8 ${stat.color}`} />
|
||||
<Card key={stat.label} className="flex items-center gap-2.5">
|
||||
<Icon className={`w-6 h-6 ${stat.color}`} />
|
||||
<div>
|
||||
<div className="text-xl font-bold">{stat.value}</div>
|
||||
<div className="text-base font-bold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -160,17 +160,17 @@ export default function Dashboard() {
|
||||
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><Briefcase className="w-5 h-5" />本月工作动态</h2>
|
||||
<span className="text-sm text-gray-400">{activities?.month}</span>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
||||
<span className="text-xs text-gray-400">{activities?.month}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{activityItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.label} className="flex flex-col items-center p-3 rounded-lg bg-gray-50">
|
||||
<Icon className={`w-5 h-5 mb-1 ${item.color}`} />
|
||||
<div className="text-lg font-bold">{item.value}</div>
|
||||
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
|
||||
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
|
||||
<div className="text-xs font-bold">{item.value}</div>
|
||||
<div className="text-xs text-gray-500">{item.label}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -180,18 +180,18 @@ export default function Dashboard() {
|
||||
|
||||
{/* 风险分布 */}
|
||||
<Card>
|
||||
<h2 className="font-semibold mb-4">风险分布</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<h2 className="font-medium mb-3">风险分布</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">{data.riskDistribution.contract}</div>
|
||||
<div className="text-lg font-bold text-primary">{data.riskDistribution.contract}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">合同风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-warning">{data.riskDistribution.salary}</div>
|
||||
<div className="text-lg font-bold text-warning">{data.riskDistribution.salary}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">薪资风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-danger">{data.riskDistribution.termination}</div>
|
||||
<div className="text-lg font-bold text-danger">{data.riskDistribution.termination}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">解聘风险</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,18 +202,18 @@ export default function Dashboard() {
|
||||
{/* 薪税 Tab */}
|
||||
{activeTab === 'payroll' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><Calculator className="w-5 h-5" />本月薪税费用总览</h2>
|
||||
<Link to="/money" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{payroll && payroll.payslipCount > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 工资构成 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">工资构成</div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1.5">工资构成</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{payrollItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
@@ -223,7 +223,7 @@ export default function Dashboard() {
|
||||
<Icon className={`w-4 h-4 ${item.color}`} />
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
|
||||
<span className={`text-xs font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -233,17 +233,17 @@ export default function Dashboard() {
|
||||
{/* 应发合计 */}
|
||||
<div className="flex items-center justify-between border-t border-b py-2">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-lg font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
||||
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 扣减项 */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-600 mb-2">扣减项</div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1.5">扣减项</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{deductionItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
<span className="text-sm font-medium text-danger">{fmt(item.value)}</span>
|
||||
<span className="text-xs font-medium text-danger">{fmt(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -252,40 +252,40 @@ export default function Dashboard() {
|
||||
{/* 员工实发 */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" />员工实发工资</span>
|
||||
<span className="text-lg font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
||||
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 企业成本 */}
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="text-sm font-medium text-gray-600 mb-1">企业用工成本</div>
|
||||
<div className="border-t pt-2 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">企业用工成本</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业社保</span>
|
||||
<span className="text-sm font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
|
||||
<span className="text-xs font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业公积金</span>
|
||||
<span className="text-sm font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
|
||||
<span className="text-xs font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" />工资总额</span>
|
||||
<span className="text-sm font-medium text-green-700">{fmt(payroll.totalPay)}</span>
|
||||
<span className="text-xs font-medium text-green-700">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{payroll.severancePay > 0 && (
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" />经济补偿金</span>
|
||||
<span className="text-sm font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
|
||||
<span className="text-xs font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" />企业总成本</span>
|
||||
<span className="text-lg font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
||||
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工资条确认状态 */}
|
||||
<div className="flex items-center gap-4 text-sm border-t pt-3">
|
||||
<div className="flex items-center gap-3 text-xs border-t pt-2">
|
||||
<span className="text-gray-500">工资条确认:</span>
|
||||
<span className="text-safe">已确认 {payroll.confirmedPayslips}</span>
|
||||
<span className="text-warning">未确认 {payroll.unconfirmedPayslips}</span>
|
||||
@@ -300,12 +300,12 @@ export default function Dashboard() {
|
||||
|
||||
{/* 风险提醒 Tab */}
|
||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<span className="text-sm text-gray-400">{filteredTodos.length} 项</span>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<span className="text-xs text-gray-400">{filteredTodos.length} 项</span>
|
||||
</div>
|
||||
|
||||
{filteredTodos.length === 0 ? (
|
||||
@@ -317,12 +317,12 @@ export default function Dashboard() {
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -354,20 +354,20 @@ export default function Dashboard() {
|
||||
{/* 已办事项 */}
|
||||
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold flex items-center gap-2"><CheckSquare className="w-5 h-5 text-safe" />已办事项</h2>
|
||||
<span className="text-sm text-gray-400">{data.resolvedTodos.length} 项</span>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
||||
<span className="text-xs text-gray-400">{data.resolvedTodos.length} 项</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{data.resolvedTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
|
||||
<span className="text-xs text-gray-600 line-through">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400">{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
+797
-256
File diff suppressed because it is too large
Load Diff
+470
-155
File diff suppressed because it is too large
Load Diff
@@ -40,8 +40,8 @@ export default function Settings() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">系统设置</h1>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">系统设置</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{sections.map((s) => {
|
||||
@@ -50,7 +50,7 @@ export default function Settings() {
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setActiveSection(s.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -82,7 +82,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">企业信息</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>企业名称</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
|
||||
@@ -126,7 +126,7 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
@@ -176,8 +176,8 @@ function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="添加用户">
|
||||
<div className="space-y-4">
|
||||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
<div className="space-y-3">
|
||||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
@@ -225,17 +225,17 @@ function PlanSettings({ orgData }: { orgData: any }) {
|
||||
<Card key={p.key}>
|
||||
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
|
||||
<div className="font-medium">{p.label}</div>
|
||||
<div className={`text-lg font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
|
||||
<div className={`text-base font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{p.features.map((f, i) => (
|
||||
<div key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<div key={i} className="text-xs text-gray-600 flex items-center gap-2">
|
||||
<span className="text-safe">✓</span> {f}
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2">
|
||||
{plan === p.key ? (
|
||||
<div className="text-sm text-center text-primary font-medium">当前套餐</div>
|
||||
<div className="text-xs text-center text-primary font-medium">当前套餐</div>
|
||||
) : (
|
||||
<Button variant="secondary" className="w-full" size="sm">升级</Button>
|
||||
)}
|
||||
@@ -288,12 +288,12 @@ function NotificationSettings() {
|
||||
const logs = logsData?.items || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">通知设置</h2>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">合同到期提醒</span>
|
||||
<span className="text-xs">合同到期提醒</span>
|
||||
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
|
||||
</label>
|
||||
<div>
|
||||
@@ -301,19 +301,19 @@ function NotificationSettings() {
|
||||
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
|
||||
</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">未签合同提醒</span>
|
||||
<span className="text-xs">未签合同提醒</span>
|
||||
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">加班超时提醒</span>
|
||||
<span className="text-xs">加班超时提醒</span>
|
||||
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">工资条发布通知</span>
|
||||
<span className="text-xs">工资条发布通知</span>
|
||||
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
|
||||
</label>
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<div className="text-sm font-medium">月度事务提醒</div>
|
||||
<div className="text-xs font-medium">月度事务提醒</div>
|
||||
<div className="text-xs text-gray-400">设置每月截止日,到期后自动生成待办提醒</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -339,7 +339,7 @@ function NotificationSettings() {
|
||||
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
</div>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm">邮件通知</span>
|
||||
<span className="text-xs">邮件通知</span>
|
||||
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
|
||||
</label>
|
||||
{form.emailNotify && (
|
||||
@@ -362,12 +362,12 @@ function NotificationSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
{checkResult && (
|
||||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm mb-3">{checkResult}</div>
|
||||
<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-sm border-b last:border-0 py-2">
|
||||
<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-400 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
|
||||
@@ -375,7 +375,7 @@ function NotificationSettings() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm text-center py-4">暂无通知记录</div>
|
||||
<div className="text-gray-400 text-xs text-center py-4">暂无通知记录</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -275,8 +275,8 @@ export default function Termination() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-semibold">解聘助手</h1>
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">解聘助手</h1>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -289,11 +289,11 @@ export default function Termination() {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-2 text-sm text-gray-500">Step {step + 1}/5:{STEPS[step]}</div>
|
||||
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/5:{STEPS[step]}</div>
|
||||
|
||||
{/* Step 1: 选择员工 */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
@@ -304,7 +304,7 @@ export default function Termination() {
|
||||
</Select>
|
||||
</div>
|
||||
{selectedEmployee && (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee.name}({selectedEmployee.department})</div>
|
||||
<div>入职日期:{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
|
||||
<div>月工资:¥{fmt(selectedEmployee.monthlySalary)}</div>
|
||||
@@ -330,11 +330,11 @@ export default function Termination() {
|
||||
)}
|
||||
{profile && suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">📋 解聘方式建议</div>
|
||||
<div className="text-xs font-medium">📋 解聘方式建议</div>
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`px-3 py-2 rounded-md text-sm ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
|
||||
className={`px-3 py-2 rounded-md text-xs ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
|
||||
>
|
||||
<div className="font-medium">{s.label}</div>
|
||||
<div className="text-xs mt-0.5">{s.why}</div>
|
||||
@@ -343,17 +343,17 @@ export default function Termination() {
|
||||
</div>
|
||||
)}
|
||||
{employeeId && !profile && (
|
||||
<div className="text-sm text-gray-400">加载员工档案中...</div>
|
||||
<div className="text-xs text-gray-400">加载员工档案中...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: 解聘方式 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-md p-3 space-y-1">
|
||||
<div className="text-sm font-medium text-blue-700">💡 系统建议</div>
|
||||
<div className="text-xs font-medium text-blue-700">💡 系统建议</div>
|
||||
{suggestions.filter((s) => s.reason).map((s, i) => (
|
||||
<div key={i} className="text-xs text-blue-600">
|
||||
{s.label}:{s.why}
|
||||
@@ -371,7 +371,7 @@ export default function Termination() {
|
||||
>
|
||||
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<div className="text-xs flex items-center gap-2">
|
||||
{r.label}
|
||||
{suggested && <span className="text-xs text-primary font-medium">推荐</span>}
|
||||
</div>
|
||||
@@ -392,12 +392,12 @@ export default function Termination() {
|
||||
{riskAssessment && riskAssessment.warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{riskAssessment.warnings.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
|
||||
<label className="flex items-center gap-2 text-xs px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
|
||||
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
|
||||
我已了解风险,继续操作
|
||||
</label>
|
||||
@@ -416,7 +416,7 @@ export default function Termination() {
|
||||
checked={checklist[item.key] || false}
|
||||
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -424,15 +424,15 @@ export default function Termination() {
|
||||
|
||||
{/* Step 4: 费用结算 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>当地社平工资(选填)</Label>
|
||||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||||
</div>
|
||||
{costResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 员工概况 */}
|
||||
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
|
||||
<div className="font-medium">{selectedEmployee?.name}({selectedEmployee?.department})</div>
|
||||
<div>工作年限:{costResult.years}年{costResult.remainingMonths}个月</div>
|
||||
<div>月工资:¥{fmt(costResult.wage)}/月</div>
|
||||
@@ -443,7 +443,7 @@ export default function Termination() {
|
||||
|
||||
{/* 经济补偿金 / 赔偿金 */}
|
||||
{costResult.noComp ? (
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
||||
员工过错解除,无需支付经济补偿金
|
||||
</div>
|
||||
) : (
|
||||
@@ -452,22 +452,22 @@ export default function Termination() {
|
||||
<Calculator className="w-4 h-4" />
|
||||
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">补偿月数:{costResult.cappedMonths}个月</div>
|
||||
<div className="text-sm text-gray-500">计算基数:¥{fmt(costResult.cappedWage)}/月</div>
|
||||
<div className="text-xs text-gray-500">补偿月数:{costResult.cappedMonths}个月</div>
|
||||
<div className="text-xs text-gray-500">计算基数:¥{fmt(costResult.cappedWage)}/月</div>
|
||||
{costResult.isIllegal && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">经济补偿金</span>
|
||||
<span>¥{fmt(costResult.basePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2)' : '补偿金'}</span>
|
||||
<span className={`text-lg font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
|
||||
<span className={`text-base font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
|
||||
¥{fmt(costResult.severancePay)}
|
||||
</span>
|
||||
</div>
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">代通知金</span>
|
||||
<span>¥{fmt(costResult.noticePay)}</span>
|
||||
</div>
|
||||
@@ -491,12 +491,12 @@ export default function Termination() {
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
未签劳动合同双倍工资
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">双倍工资起算:{costResult.doubleStartDate}</div>
|
||||
<div className="text-sm text-gray-500">双倍工资截止:{costResult.doubleEndDate}</div>
|
||||
<div className="text-sm text-gray-500">赔偿月数:{costResult.doubleMonths}个月</div>
|
||||
<div className="text-xs text-gray-500">双倍工资起算:{costResult.doubleStartDate}</div>
|
||||
<div className="text-xs text-gray-500">双倍工资截止:{costResult.doubleEndDate}</div>
|
||||
<div className="text-xs text-gray-500">赔偿月数:{costResult.doubleMonths}个月</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">双倍工资赔偿</span>
|
||||
<span className="text-lg font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
|
||||
<span className="text-base font-bold text-warning">¥{fmt(costResult.doublePay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({costResult.doubleMonths}个月 × ¥{fmt(costResult.wage)})</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
|
||||
@@ -510,12 +510,12 @@ export default function Termination() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">合计应付</span>
|
||||
<span className="text-xl font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
|
||||
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!costResult.noComp && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</span>
|
||||
</div>
|
||||
@@ -527,18 +527,18 @@ export default function Termination() {
|
||||
|
||||
{/* Step 5: 解聘材料 */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{saveMutation.isError ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertTriangle className="w-12 h-12 text-danger mx-auto" />
|
||||
<div className="text-danger font-medium mt-2">保存失败</div>
|
||||
<div className="text-sm text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
|
||||
<div className="text-xs text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
|
||||
<Button onClick={() => setStep(3)} className="mt-4">返回修改</Button>
|
||||
</div>
|
||||
) : saveMutation.isPending ? (
|
||||
<div className="text-center py-8 text-gray-400">保存中...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 成功提示 */}
|
||||
<div className="flex items-center gap-2 text-safe">
|
||||
<Check className="w-5 h-5" />
|
||||
@@ -556,11 +556,11 @@ export default function Termination() {
|
||||
</div>
|
||||
|
||||
{/* 1. 解聘通知书 */}
|
||||
<div className="border rounded-lg p-6 space-y-4 print:shadow-none">
|
||||
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-bold">解除劳动合同通知书</h2>
|
||||
<h2 className="text-base font-bold">解除劳动合同通知书</h2>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 space-y-3">
|
||||
<div className="text-xs text-gray-700 space-y-3">
|
||||
<p><strong>{selectedEmployee?.name}</strong> 先生/女士:</p>
|
||||
<p>
|
||||
您于 <strong>{selectedEmployee?.hireDate?.toString().slice(0, 10)}</strong> 入职我公司{selectedEmployee?.department}部门。
|
||||
@@ -600,7 +600,7 @@ export default function Termination() {
|
||||
{costResult && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" />费用结算明细</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between"><span>工作年限</span><span>{costResult.years}年{costResult.remainingMonths}个月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(costResult.wage)}/月</span></div>
|
||||
{costResult.capped && <div className="text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>}
|
||||
@@ -623,7 +623,7 @@ export default function Termination() {
|
||||
{/* 3. 合规检查清单 */}
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="font-medium flex items-center gap-2"><Shield className="w-4 h-4" />合规检查清单</h3>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="text-xs space-y-1">
|
||||
{checklistItems?.map((item) => (
|
||||
<div key={item.key} className="flex items-center gap-2">
|
||||
<span className={checklist[item.key] ? 'text-safe' : 'text-danger'}>
|
||||
@@ -661,7 +661,7 @@ export default function Termination() {
|
||||
}, {})
|
||||
return Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="space-y-1">
|
||||
<div className="text-sm font-medium text-gray-700">{category as string}</div>
|
||||
<div className="text-xs font-medium text-gray-700">{category as string}</div>
|
||||
{(items as any[]).map((e: any, i: number) => (
|
||||
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -677,7 +677,7 @@ export default function Termination() {
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">加载证据链中...</div>
|
||||
<div className="text-xs text-gray-400">加载证据链中...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function ContractConfirm() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-lg font-semibold mb-2">合同签署确认成功</h1>
|
||||
<h1 className="text-sm font-semibold mb-2">合同签署确认成功</h1>
|
||||
<p className="text-sm text-gray-500">已记录您的签署确认时间和 IP 地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ export default function ContractConfirm() {
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<PenTool className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">合同签署确认</h1>
|
||||
<h1 className="text-sm font-semibold">合同签署确认</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -74,7 +74,7 @@ export default function ContractConfirm() {
|
||||
</Card>
|
||||
) : data ? (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.orgName} 与 {data.employeeName} 的劳动合同
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function MyContract() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">我的劳动合同</h1>
|
||||
<h1 className="text-sm font-semibold">我的劳动合同</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
@@ -51,7 +51,7 @@ export default function MyContract() {
|
||||
) : !contract ? (
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* 到期提醒 */}
|
||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Onboarding() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-lg font-semibold mb-2">信息提交成功</h1>
|
||||
<h1 className="text-sm font-semibold mb-2">信息提交成功</h1>
|
||||
<p className="text-sm text-gray-500">HR 将审核您的信息,请耐心等待。</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +65,7 @@ export default function Onboarding() {
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">入职信息填报</h1>
|
||||
<h1 className="text-sm font-semibold">入职信息填报</h1>
|
||||
</div>
|
||||
|
||||
{orgName && (
|
||||
@@ -79,7 +79,7 @@ export default function Onboarding() {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Payslip() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-lg font-semibold">我的工资条</h1>
|
||||
<h1 className="text-sm font-semibold">我的工资条</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
@@ -93,7 +93,7 @@ export default function Payslip() {
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-xl font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
|
||||
<span className="text-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function PortalLogin() {
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手 — 员工端</span>
|
||||
<span className="text-base font-bold">用工合规助手 — 员工端</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -82,7 +82,7 @@ export default function PortalLogin() {
|
||||
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
@@ -96,7 +96,7 @@ export default function PortalLogin() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
surface: '#F8FAFC',
|
||||
},
|
||||
maxWidth: {
|
||||
content: '960px',
|
||||
content: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user