From 9946197d2074ed724144ce3f4835bfdda895a95d Mon Sep 17 00:00:00 2001 From: selfrelease Date: Fri, 31 Jul 2026 18:07:49 +0800 Subject: [PATCH] =?UTF-8?q?Sprint=203:=20=E5=91=98=E5=B7=A5=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=BC=98=E5=8C=96=20+=20Profile=20Shell=20+=20?= =?UTF-8?q?=E7=A6=BB=E8=81=8C=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20+=20=E5=90=88=E5=90=8C=E7=B1=BB=E5=9E=8B=E6=89=A9=E5=85=85?= =?UTF-8?q?=20+=20=E6=9C=88=E5=BA=A6=E7=A4=BE=E4=BF=9D=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20+=20=E5=95=86=E9=99=A9=E7=AE=A1=E7=90=86Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3-1: Roster.tsx 添加 InlineAlert 合同风险提示 + 快捷筛选标签 S3-2: 新建 EmployeeProfileShell.tsx 统一员工详情布局,重构 EmployeeProfile.tsx S3-3: Termination.tsx 集成 Stepper 步骤条 + InlineAlert 风险提示 S3-4: schema.prisma ContractType 枚举扩充 DISPATCH/OUTSOURCING/PARTTIME + 后端接口 + 前端选择器 S3-5: SocialInsurance.tsx 月度办理 Tab 使用 InlineAlert 替换原始提示 S3-6: SocialInsurance.tsx 新增商险管理 Tab(方案CRUD + 参保人员列表) --- backend/prisma/schema.prisma | 3 + backend/src/routes/roster.routes.ts | 7 +- frontend/src/pages/Roster.tsx | 61 +++- frontend/src/pages/SocialInsurance.tsx | 291 +++++++++++++++++- frontend/src/pages/Termination.tsx | 33 +- frontend/src/pages/roster/EmployeeProfile.tsx | 82 ++--- .../src/pages/roster/EmployeeProfileShell.tsx | 240 +++++++++++++++ 7 files changed, 636 insertions(+), 81 deletions(-) create mode 100644 frontend/src/pages/roster/EmployeeProfileShell.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index da9bf87..522389b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -38,6 +38,9 @@ enum ContractType { UNSIGNED LABOR INTERNSHIP + DISPATCH + OUTSOURCING + PARTTIME } enum SignMethod { diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 522b5f0..2aa702a 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -1201,8 +1201,11 @@ router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => { const types = [ { value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true }, { value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false }, - { value: 'LABOR', label: '劳务协议', hasEndDate: false }, - { value: 'INTERNSHIP', label: '实习协议', hasEndDate: false }, + { value: 'LABOR', label: '劳务协议', hasEndDate: true }, + { value: 'INTERNSHIP', label: '实习协议', hasEndDate: true }, + { value: 'DISPATCH', label: '劳务派遣', hasEndDate: true }, + { value: 'OUTSOURCING', label: '业务外包', hasEndDate: true }, + { value: 'PARTTIME', label: '兼职协议', hasEndDate: true }, { value: 'UNSIGNED', label: '未签合同', hasEndDate: false }, ] res.json({ success: true, data: types }) diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index cef0ba7..048dac1 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useMemo } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' @@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal' import Pagination from '../components/ui/Pagination' import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared' import EmployeeProfile from './roster/EmployeeProfile' +import { InlineAlert } from '../components/ui/InlineAlert' import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals' import { ImportSettings } from './Settings' @@ -219,6 +220,23 @@ export default function Roster() { const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment + /** 计算合同风险统计(基于当前页数据) */ + const riskStats = useMemo(() => { + const expiring = employees.filter((e: any) => e.contractStatus === 'expiring').length + const expired = employees.filter((e: any) => e.contractStatus === 'expired').length + const unsigned = employees.filter((e: any) => ['unsigned', 'unsigned_over_30', 'unsigned_over_year'].includes(e.contractStatus)).length + const probation = employees.filter((e: any) => e.probationInfo?.isProbation && e.probationInfo?.isExpiring).length + return { expiring, expired, unsigned, probation } + }, [employees]) + + /** 快捷筛选标签 */ + const quickFilters = [ + { key: 'expiring', label: '即将到期', count: riskStats.expiring, color: 'amber', filter: { status: '', contractStatus: 'expiring', department: '' } }, + { key: 'expired', label: '已到期', count: riskStats.expired, color: 'rose', filter: { status: '', contractStatus: 'expired', department: '' } }, + { key: 'unsigned', label: '未签合同', count: riskStats.unsigned, color: 'rose', filter: { status: '', contractStatus: 'unsigned', department: '' } }, + { key: 'probation', label: '试用期即将到期', count: riskStats.probation, color: 'amber', filter: { status: 'PROBATION', contractStatus: '', department: '' } }, + ].filter(f => f.count > 0) + const { data: departmentList } = useQuery({ queryKey: ['roster-departments'], queryFn: async () => { @@ -332,6 +350,44 @@ export default function Roster() { )} + {/* 合同风险提示 */} + {!isLoading && (riskStats.expired > 0 || riskStats.unsigned > 0) && ( + + {riskStats.expired > 0 && 当前页有 {riskStats.expired} 人合同已到期;} + {riskStats.unsigned > 0 && {riskStats.unsigned} 人未签合同;} + 请及时处理以避免法律风险。 + + )} + + {/* 快捷筛选标签 */} + {!isLoading && quickFilters.length > 0 && ( +
+ 快捷筛选: + {quickFilters.map((f) => ( + + ))} +
+ )} + {isLoading ? (
加载中...
) : filtered.length === 0 ? ( @@ -422,6 +478,9 @@ export default function Roster() { UNFIXED: { label: '劳动合同-无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' }, LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' }, INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' }, + DISPATCH: { label: '劳务派遣', style: 'bg-cyan-50 text-cyan-700 border border-cyan-200' }, + OUTSOURCING: { label: '业务外包', style: 'bg-slate-50 text-slate-700 border border-slate-200' }, + PARTTIME: { label: '兼职协议', style: 'bg-indigo-50 text-indigo-700 border border-indigo-200' }, UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' }, } const ct = e.latestContract?.contractType diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index c4eedd9..cf65d7c 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' -import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X } from 'lucide-react' +import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X, Shield } from 'lucide-react' +import { InlineAlert } from '../components/ui/InlineAlert' import api from '../lib/api' import { useAuthStore } from '../store/authStore' import Card from '../components/ui/Card' @@ -15,7 +16,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig export default function SocialInsurance() { const queryClient = useQueryClient() const confirm = useConfirm() - const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly') + const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly') const [city, setCity] = useState('北京') const [showAddCity, setShowAddCity] = useState(false) const [newCityName, setNewCityName] = useState('') @@ -374,7 +375,7 @@ export default function SocialInsurance() { {/* Tab 切换 + 城市选择 */}
- {(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => ( + {(['monthly', 'social', 'housing', 'deduction', 'commercial'] as const).map((t) => ( ))} {(tab === 'social' || tab === 'housing') && ( @@ -1001,9 +1002,9 @@ export default function SocialInsurance() { )}
)} -
+ 展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。 -
+ {(() => { if (!monthlyProcessed) { return
选择月份后点击「获取」按钮,获取当月增减员及在保人员列表
@@ -1138,6 +1139,11 @@ export default function SocialInsurance() { )} + {/* ========== 商险管理 Tab ========== */} + {tab === 'commercial' && ( + + )} +

社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。 发薪批次计算时按批次月份自动匹配对应版本配置。 @@ -1618,3 +1624,276 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: ) } + +/** + * 商险管理 Tab — 管理商业保险(意外险、补充医疗、雇主责任险等) + * 支持查看商险方案、参保人员、保单信息 + */ +function CommercialInsuranceTab() { + const queryClient = useQueryClient() + const confirm = useConfirm() + const [showAddPlan, setShowAddPlan] = useState(false) + const [editingPlan, setEditingPlan] = useState(null) + const [selectedPlanId, setSelectedPlanId] = useState(null) + const [newPlan, setNewPlan] = useState({ + name: '', + type: 'ACCIDENT', + provider: '', + policyNo: '', + premium: 0, + coverageAmount: 0, + effectiveFrom: new Date().toISOString().slice(0, 10), + effectiveTo: '', + description: '', + }) + + /** 商险类型映射 */ + const INSURANCE_TYPES: Record = { + ACCIDENT: { label: '意外伤害险', color: 'bg-orange-50 text-orange-700 border border-orange-200' }, + SUPPLEMENTARY_MEDICAL: { label: '补充医疗保险', color: 'bg-blue-50 text-blue-700 border border-blue-200' }, + EMPLOYER_LIABILITY: { label: '雇主责任险', color: 'bg-purple-50 text-purple-700 border border-purple-200' }, + CRITICAL_ILLNESS: { label: '重大疾病险', color: 'bg-rose-50 text-rose-700 border border-rose-200' }, + GROUP_LIFE: { label: '团体寿险', color: 'bg-teal-50 text-teal-700 border border-teal-200' }, + OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' }, + } + + /** 获取商险方案列表 */ + const { data: plans = [], isLoading } = useQuery({ + queryKey: ['commercial-insurance-plans'], + queryFn: async () => { + const res = await api.get('/commercial-insurance/plans') as any + return res.data || [] + }, + }) + + /** 获取选中方案的参保人员 */ + const { data: enrollments = [], isLoading: enrollLoading } = useQuery({ + queryKey: ['commercial-insurance-enrollments', selectedPlanId], + queryFn: async () => { + if (!selectedPlanId) return [] + const res = await api.get(`/commercial-insurance/plans/${selectedPlanId}/enrollments`) as any + return res.data || [] + }, + enabled: !!selectedPlanId, + }) + + /** 创建/更新商险方案 */ + const savePlanMutation = useMutation({ + mutationFn: async (data: any) => { + if (editingPlan) { + return api.put(`/commercial-insurance/plans/${editingPlan.id}`, data) as any + } + return api.post('/commercial-insurance/plans', data) as any + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] }) + setShowAddPlan(false) + setEditingPlan(null) + setNewPlan({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' }) + toast.success(editingPlan ? '商险方案已更新' : '商险方案已创建') + }, + onError: () => toast.error('保存失败'), + }) + + /** 删除商险方案 */ + const deletePlanMutation = useMutation({ + mutationFn: (id: string) => api.delete(`/commercial-insurance/plans/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] }) + setSelectedPlanId(null) + toast.success('商险方案已删除') + }, + }) + + const handleEdit = (plan: any) => { + setEditingPlan(plan) + setNewPlan({ ...plan }) + setShowAddPlan(true) + } + + const handleSave = () => { + if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return } + if (!newPlan.provider?.trim()) { toast.error('请填写保险公司'); return } + savePlanMutation.mutate(newPlan) + } + + if (isLoading) return

加载中...
+ + return ( +
+
+
+ +

商业保险管理

+
+ +
+ + + 商业保险为社保之外的自愿性补充保障,包括意外伤害险、补充医疗、雇主责任险等。此处管理商险方案及参保人员。 + + + {/* 商险方案列表 */} + {plans.length === 0 ? ( +
暂无商险方案,点击「新增方案」创建
+ ) : ( +
+ {plans.map((plan: any) => { + const typeCfg = INSURANCE_TYPES[plan.type] || INSURANCE_TYPES.OTHER + const isSelected = selectedPlanId === plan.id + return ( + +
setSelectedPlanId(isSelected ? null : plan.id)}> +
+
+ {typeCfg.label} +

{plan.name}

+
+
e.stopPropagation()}> + + +
+
+
+
保险公司{plan.provider}
+
保单号{plan.policyNo || '—'}
+
保费(年)¥{fmt(plan.premium)}
+
保额¥{fmt(plan.coverageAmount)}
+
保障期间{plan.effectiveFrom} ~ {plan.effectiveTo || '长期'}
+
+ {plan.description &&

{plan.description}

} +
+
+ ) + })} +
+ )} + + {/* 参保人员列表 */} + {selectedPlanId && ( + +

参保人员({enrollments.length}人)

+ {enrollLoading ? ( +
加载中...
+ ) : enrollments.length === 0 ? ( +
暂无参保人员
+ ) : ( +
+ + + + + + + + + + + + + {enrollments.map((e: any) => ( + + + + + + + + + ))} + +
姓名部门身份证号保费生效日期状态
{e.name}{e.department}{e.idCardMasked || '—'}¥{fmt(e.premium || 0)}{e.effectiveFrom || '—'} + + {e.status === 'ACTIVE' ? '有效' : '已终止'} + +
+
+ )} +
+ )} + + {/* 新增/编辑方案弹窗 */} + {showAddPlan && ( +
setShowAddPlan(false)}> + +
e.stopPropagation()}> +
+

{editingPlan ? '编辑商险方案' : '新增商险方案'}

+ +
+
+
+ + setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度员工意外险" /> +
+
+
+ + +
+
+ + setNewPlan({ ...newPlan, provider: e.target.value })} placeholder="如:中国人寿" /> +
+
+
+
+ + setNewPlan({ ...newPlan, policyNo: e.target.value })} placeholder="保单编号" /> +
+
+ + setNewPlan({ ...newPlan, premium: parseFloat(e.target.value) || 0 })} /> +
+
+
+
+ + setNewPlan({ ...newPlan, coverageAmount: parseFloat(e.target.value) || 0 })} /> +
+
+ + setNewPlan({ ...newPlan, effectiveTo: e.target.value })} /> +
+
+
+ + setNewPlan({ ...newPlan, effectiveFrom: e.target.value })} /> +
+
+ + setNewPlan({ ...newPlan, description: e.target.value })} placeholder="保障范围、免赔额等" /> +
+
+ + +
+
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/Termination.tsx b/frontend/src/pages/Termination.tsx index 8623f01..79dcb49 100644 --- a/frontend/src/pages/Termination.tsx +++ b/frontend/src/pages/Termination.tsx @@ -2,6 +2,8 @@ import { useState, useMemo, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react' +import { Stepper } from '../components/ui/Stepper' +import { InlineAlert } from '../components/ui/InlineAlert' import jsPDF from 'jspdf' import api from '../lib/api' import { useAuthStore } from '../store/authStore' @@ -976,15 +978,16 @@ export default function Termination() { {/* 左侧:向导 */}
- {/* 进度条 */} -
- {STEPS.map((_s, i) => ( -
-
- {i < STEPS.length - 1 &&
} -
- ))} -
+ {/* 步骤条 */} + ({ + key: String(i), + title, + status: i < step ? 'complete' : i === step ? 'current' : 'pending', + }))} + onStepClick={(key) => { const i = parseInt(key); if (i <= step) setStep(i) }} + className="mb-4" + />
Step {step + 1}/6:{STEPS[step]}
@@ -1161,10 +1164,9 @@ export default function Termination() { {riskAssessment && riskAssessment.warnings.length > 0 && (
{riskAssessment.warnings.map((w, i) => ( -
- + {w} -
+ ))}