优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换

- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
This commit is contained in:
freedakgmail
2026-08-04 07:53:37 +08:00
parent 1da385cd5d
commit 2968484d2d
109 changed files with 8950 additions and 5926 deletions
+4
View File
@@ -40,8 +40,10 @@ const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
const CalendarPage = lazy(() => import('./pages/Calendar'))
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
// Sprint 4-5 新增页面
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
@@ -191,6 +193,7 @@ export default function App() {
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
<Route path="/risk-center" element={<ProtectedRoute><AdminLayout><RiskCenter /></AdminLayout></ProtectedRoute>} />
<Route path="/salary-dashboard" element={<ProtectedRoute><AdminLayout><SalaryDashboard /></AdminLayout></ProtectedRoute>} />
@@ -208,6 +211,7 @@ export default function App() {
<Route path="/portal/contract-confirm" element={<PortalLayoutWrapper showNav={false}><ContractConfirm /></PortalLayoutWrapper>} />
<Route path="/portal/policies" element={<PortalLayoutWrapper><MyPolicies /></PortalLayoutWrapper>} />
<Route path="/portal/attendance" element={<PortalLayoutWrapper><MyAttendance /></PortalLayoutWrapper>} />
<Route path="/portal/leave" element={<PortalLayoutWrapper><MyLeave /></PortalLayoutWrapper>} />
<Route path="/portal/auto-login" element={<PortalLayoutWrapper showNav={false}><AutoLogin /></PortalLayoutWrapper>} />
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
@@ -44,9 +44,9 @@ export default function AcceptanceTestModal({ open, onClose }: { open: boolean;
<X className="w-4 h-4" />
</button>
</div>
{/* iframe 加载验收测试页面 */}
{/* iframe 加载验收测试页面 — 带版本参数防止缓存 */}
<iframe
src="/acceptance-test.html"
src={`/acceptance-test.html?v=${__APP_VERSION__}`}
className="w-full"
style={{ height: 'calc(95vh - 42px)', border: 'none' }}
title="验收测试清单"
-59
View File
@@ -1,59 +0,0 @@
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4">
<div className="text-6xl">😵</div>
<h2 className="text-xl font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-500 text-center max-w-md">
{this.state.error?.message || '发生了未知错误,请刷新页面重试'}
</p>
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { HelpCircle, Search, ChevronDown, ChevronRight, Sparkles,
Home, Users, FileText, Calculator, Shield, UserX, Bot,
Bell, Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
Home, Users, FileText, Calculator, Bot,
Settings, Lightbulb, AlertTriangle, CheckCircle, Phone } from 'lucide-react'
import Modal from './ui/Modal'
import { aiApi } from '../lib/api-services'
import clsx from 'clsx'
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useMemo, useEffect, useCallback } from 'react'
import { Star, ChevronDown, ChevronRight, Check, Search, ClipboardList, Send, Save, RotateCcw } from 'lucide-react'
import { Star, ChevronDown, ChevronRight, Search, ClipboardList, Send, Save, RotateCcw } from 'lucide-react'
import Modal from './ui/Modal'
import { toast } from 'sonner'
import { surveyApi } from '../lib/api-services'
@@ -17,6 +17,7 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
'/roster': { group: '员工管理', label: '花名册' },
'/work-process': { group: '员工管理', label: '用工办理' },
'/attendance': { group: '员工管理', label: '考勤确认' },
'/leave-approval': { group: '员工管理', label: '休假审批' },
'/termination': { group: '员工管理', label: '解聘补偿' },
'/special-status': { group: '员工管理', label: '特殊状态' },
'/money': { group: '薪税社保', label: '薪税管理' },
@@ -4,7 +4,7 @@
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck, Home, UserX, ClipboardList } from 'lucide-react'
import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck, Home } from 'lucide-react'
import Logo from '../../components/ui/Logo'
const tabItems = [
@@ -13,7 +13,7 @@ import {
Bot, BookMarked,
Bell, ScrollText, Settings,
ChevronDown, ChevronRight,
Building2, CalendarDays, ClipboardList, Heart,
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
} from 'lucide-react'
import Logo from '../ui/Logo'
@@ -33,7 +33,7 @@ const navGroups: NavGroup[] = [
title: '首页',
items: [
{ path: '/', label: '工作台', icon: LayoutDashboard },
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
{ path: '/calendar', label: '日历', icon: CalendarDays },
],
},
{
@@ -57,6 +57,7 @@ const navGroups: NavGroup[] = [
title: '时间',
items: [
{ path: '/attendance', label: '考勤排班', icon: CalendarCheck },
{ path: '/leave-approval', label: '休假审批', icon: CalendarClock },
],
},
{
@@ -66,14 +67,14 @@ const navGroups: NavGroup[] = [
{ path: '/evidence', label: '证据链', icon: FileSearch },
{ path: '/policies', label: '规章制度', icon: FileText },
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
{ path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse },
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
],
},
{
title: '更多',
items: [
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
{ path: '/ai-assistant', label: 'AI顾问', icon: Bot },
{ path: '/templates', label: '文本模板', icon: BookMarked },
{ path: '/notifications', label: '通知管理', icon: Bell },
{ path: '/audit', label: '操作日志', icon: ScrollText },
@@ -24,6 +24,7 @@ const QUICK_PAGES: SearchResult[] = [
{ type: 'page', id: 'social', title: '社保公积金', link: '/social', icon: 'shield' },
{ type: 'page', id: 'termination', title: '离职管理', link: '/termination', icon: 'userX' },
{ type: 'page', id: 'attendance', title: '考勤排班', link: '/attendance', icon: 'calendar' },
{ type: 'page', id: 'leave-approval', title: '休假审批', link: '/leave-approval', icon: 'calendar' },
{ type: 'page', id: 'risk-center', title: '风险中心', link: '/risk-center', icon: 'alert' },
{ type: 'page', id: 'salary-dashboard', title: '薪酬分析', link: '/salary-dashboard', icon: 'chart' },
{ type: 'page', id: 'policies', title: '规章制度', link: '/policies', icon: 'file' },
+2 -2
View File
@@ -23,8 +23,8 @@ export default class ErrorBoundary extends Component<Props, State> {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
componentDidCatch(_error: Error, _errorInfo: React.ErrorInfo) {
// 错误已通过 getDerivedStateFromError 捕获并展示降级 UI
}
handleReset = () => {
+37
View File
@@ -0,0 +1,37 @@
/**
* PageGuide 页面操作指导组件 — 折叠式,默认收起
* 统一用于各页面/Tab的操作说明,点击展开查看
*/
import { useState, ReactNode } from 'react'
import { ChevronRight, Lightbulb } from 'lucide-react'
interface PageGuideProps {
/** 指导标题,默认"操作说明" */
title?: string
/** 指导内容,支持字符串或自定义JSX */
children: ReactNode
/** 额外类名 */
className?: string
}
export default function PageGuide({ title = '操作说明', children, className = '' }: PageGuideProps) {
const [open, setOpen] = useState(false)
return (
<div className={`rounded-md border border-blue-100 bg-blue-50/50 ${className}`}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
<ChevronRight className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`} />
<Lightbulb className="w-3.5 h-3.5" />
{title}
</button>
{open && (
<div className="px-3 pb-2.5 text-xs text-blue-600/80 leading-relaxed">
{children}
</div>
)}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { AlertCircle, RefreshCw } from 'lucide-react'
interface QueryErrorProps {
error?: unknown
onRetry?: () => void
message?: string
}
/**
* 查询错误状态组件
* 在 useQuery 的 isError 状态下显示错误提示和重试按钮
*/
export default function QueryError({ error, onRetry, message }: QueryErrorProps) {
const errMsg = message
|| (error as any)?.response?.data?.error?.message
|| (error as any)?.message
|| '数据加载失败,请稍后重试'
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-3">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
<p className="text-sm text-gray-600 mb-3 max-w-md">{errMsg}</p>
{onRetry && (
<button
onClick={onRetry}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-primary bg-primary/5 rounded-lg hover:bg-primary/10 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}
-2
View File
@@ -2,7 +2,6 @@
* Stepper 步骤条组件 — 用于多步骤工作流引导
* 支持横向/纵向布局、可点击步骤导航、完成/当前/待办状态
*/
import { ReactNode } from 'react'
import clsx from 'clsx'
import { Check } from 'lucide-react'
@@ -24,7 +23,6 @@ interface StepperProps {
* Stepper 步骤条 — 展示工作流进度和步骤导航
*/
export function Stepper({ steps, orientation = 'horizontal', onStepClick, className }: StepperProps) {
const currentIndex = steps.findIndex(s => s.status === 'current')
if (orientation === 'vertical') {
return (
+97 -68
View File
@@ -25,10 +25,10 @@ export const authApi = {
login: (data: { phone: string; password: string }) =>
post('/auth/login', data).then(unwrap<any>()),
/** 注册 */
register: (data: any) =>
register: (data: Record<string, unknown>) =>
post('/auth/register', data).then(unwrap<any>()),
/** 平台登录 */
platformLogin: (data: any) =>
platformLogin: (data: Record<string, unknown>) =>
post('/auth/platform-login', data).then(unwrap<any>()),
/** 发送忘记密码验证码 */
forgotPasswordSendCode: (phone: string) =>
@@ -64,19 +64,19 @@ export const employeeApi = {
detail: (id: string) =>
get(`/employees/${id}`).then(unwrap<any>()),
/** 创建员工 */
create: (data: any) =>
create: (data: Record<string, unknown>) =>
post('/employees', data),
/** 更新员工 */
update: (id: string, data: any) =>
update: (id: string, data: Record<string, unknown>) =>
put(`/employees/${id}`, data),
/** 删除员工 */
remove: (id: string) =>
del(`/employees/${id}`),
/** 重新入职 */
rehire: (id: string, data: any) =>
rehire: (id: string, data: Record<string, unknown>) =>
post(`/employees/${id}/rehire`, data),
/** 添加合同 */
addContract: (data: any) =>
addContract: (data: Record<string, unknown>) =>
post('/employees/contracts', data),
/** 删除合同 */
removeContract: (contractId: string) =>
@@ -101,7 +101,7 @@ export interface RosterParams {
}
export interface RosterResponse {
data: any[]
data: Record<string, unknown>[]
pagination: { page: number; pageSize: number; total: number; totalPages: number }
globalRiskStats?: { expiring: number; expired: number; unsigned: number }
}
@@ -129,31 +129,31 @@ export const rosterApi = {
profile: (employeeId: string) =>
get(`/roster/${employeeId}/profile`).then(unwrap<any>()),
/** 调薪 */
salaryChange: (employeeId: string, data: any) =>
salaryChange: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/salary-change`, data),
/** 调岗 */
departmentChange: (employeeId: string, data: any) =>
departmentChange: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/department-change`, data),
/** 考勤记录 */
attendance: (employeeId: string, data: any) =>
attendance: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/attendance`, data),
/** 删除考勤记录 */
removeAttendance: (employeeId: string, id: string) =>
del(`/roster/${employeeId}/attendance/${id}`),
/** 培训记录 */
training: (employeeId: string, data: any) =>
training: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/training`, data),
/** 删除培训记录 */
removeTraining: (employeeId: string, id: string) =>
del(`/roster/${employeeId}/training/${id}`),
/** 绩效记录 */
performance: (employeeId: string, data: any) =>
performance: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/performance`, data),
/** 删除绩效记录 */
removePerformance: (employeeId: string, id: string) =>
del(`/roster/${employeeId}/performance/${id}`),
/** 违纪记录-创建 */
createDisciplinary: (employeeId: string, data: any) =>
createDisciplinary: (employeeId: string, data: Record<string, unknown>) =>
post(`/roster/${employeeId}/disciplinary`, data),
/** 删除违纪记录 */
removeDisciplinary: (employeeId: string, id: string) =>
@@ -164,7 +164,7 @@ export const rosterApi = {
export const attachmentApi = {
/** 添加附件 */
add: (data: any) =>
add: (data: Record<string, unknown>) =>
post('/attachments', data),
/** 获取附件列表 */
list: (employeeId: string) =>
@@ -265,25 +265,42 @@ export const attendanceApi = {
confirm: (data: { employeeId: string; month: string }) =>
post('/attendance/confirm', data).then(unwrap<any>()),
/** 创建/更新班次 */
saveShift: (data: any, editId?: string) =>
saveShift: (data: Record<string, unknown>, editId?: string) =>
editId ? put(`/attendance/shifts/${editId}`, data) : post('/attendance/shifts', data),
/** 删除班次 */
removeShift: (id: string) =>
del(`/attendance/shifts/${id}`),
/** 批量排班 */
batchAssign: (items: any[]) =>
batchAssign: (items: Record<string, unknown>[]) =>
post('/attendance/shift-assignments/batch', { items }),
/** 删除排班 */
removeAssignment: (id: string) =>
del(`/attendance/shift-assignments/${id}`),
/** 创建请假记录 */
createLeave: (data: any) =>
createLeave: (data: Record<string, unknown>) =>
post('/attendance/leaves', data),
/** 删除请假记录 */
removeLeave: (id: string) =>
del(`/attendance/leaves/${id}`),
}
// ========== 休假审批流 ==========
export const leaveApi = {
list: (params?: Record<string, unknown>) =>
get('/leaves', { params }).then(unwrap<any>()),
stats: (month?: string) =>
get('/leaves/stats', { params: { month } }).then(unwrap<any>()),
create: (data: Record<string, unknown>) =>
post('/leaves', data).then(unwrap<any>()),
approve: (id: string, action: string, remark?: string) =>
post(`/leaves/${id}/approve`, { action, remark }).then(unwrap<any>()),
cancel: (id: string) =>
post(`/leaves/${id}/cancel`).then(unwrap<any>()),
remove: (id: string) =>
del(`/leaves/${id}`),
}
// ========== AI 相关 ==========
export const aiApi = {
@@ -294,19 +311,19 @@ export const aiApi = {
conversation: (id: string) =>
get(`/ai/conversations/${id}`).then(unwrap<any>()),
/** 创建对话 */
createConversation: (data: any) =>
createConversation: (data: Record<string, unknown>) =>
post('/ai/conversations', data).then(unwrap<any>()),
/** 更新对话 */
updateConversation: (id: string, data: any) =>
updateConversation: (id: string, data: Record<string, unknown>) =>
put(`/ai/conversations/${id}`, data),
/** 删除对话 */
removeConversation: (id: string) =>
del(`/ai/conversations/${id}`),
/** AI 咨询 */
consult: (data: any) =>
consult: (data: Record<string, unknown>) =>
post('/ai/consultation', data).then(unwrap<any>()),
/** AI 上下文问答 */
contextAsk: (data: any) =>
contextAsk: (data: Record<string, unknown>) =>
post('/ai/context-ask', data).then(unwrap<any>()),
/** 合同审查上传 */
reviewUpload: (formData: FormData) =>
@@ -315,19 +332,19 @@ export const aiApi = {
review: (contractText: string) =>
post('/ai/review', { contractText }).then(unwrap<any>()),
/** 保存审查结果 */
reviewSave: (data: any) =>
reviewSave: (data: Record<string, unknown>) =>
post('/ai/review/save', data),
/** 案例匹配 */
matchCase: (scenario: string) =>
post('/ai/match-case', { scenario }).then(unwrap<any>()),
/** 案例转待办 */
caseToTodo: (data: any) =>
caseToTodo: (data: Record<string, unknown>) =>
post('/ai/case-to-todo', data),
/** RAG 知识库列表 */
ragList: () =>
get('/ai/rag/list').then(unwrap<any[]>()),
/** RAG 添加知识 */
ragAdd: (data: any) =>
ragAdd: (data: Record<string, unknown>) =>
post('/ai/rag/add', data),
/** RAG 删除知识 */
ragRemove: (id: string) =>
@@ -347,10 +364,10 @@ export const payrollApi = {
template: () =>
get('/payroll2/template').then(unwrap<any[]>()),
/** 创建模版项 */
createTemplateItem: (data: any) =>
createTemplateItem: (data: Record<string, unknown>) =>
post('/payroll2/template', data),
/** 更新模版项 */
updateTemplateItem: (id: string, data: any) =>
updateTemplateItem: (id: string, data: Record<string, unknown>) =>
put(`/payroll2/template/${id}`, data),
/** 删除模版项 */
removeTemplateItem: (id: string) =>
@@ -359,7 +376,7 @@ export const payrollApi = {
batchCheck: (month: string) =>
get('/payroll2/batches/check', { params: { month } }).then(unwrap<any>()),
/** 批次列表 */
batches: (params?: any) =>
batches: (params?: Record<string, unknown>) =>
get('/payroll2/batches', { params }).then(unwrap<any[]>()),
/** 归档批次列表 */
archivedBatches: () =>
@@ -368,7 +385,7 @@ export const payrollApi = {
batchDetail: (id: string) =>
get(`/payroll2/batches/${id}`).then(unwrap<any>()),
/** 创建批次 */
createBatch: (data: any) =>
createBatch: (data: Record<string, unknown>) =>
post('/payroll2/batches', data),
/** 重命名批次 */
renameBatch: (batchId: string, name: string) =>
@@ -395,8 +412,11 @@ export const payrollApi = {
removeBatchEmployee: (batchId: string, employeeId: string) =>
del(`/payroll2/batches/${batchId}/employees/${employeeId}`),
/** 更新批次条目 */
updateBatchEntry: (batchId: string, employeeId: string, data: any) =>
updateBatchEntry: (batchId: string, employeeId: string, data: Record<string, unknown>) =>
put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data),
/** 获取条目个税计算明细 */
taxDetail: (batchId: string, employeeId: string) =>
get(`/payroll2/batches/${batchId}/entries/${employeeId}/tax-detail`).then(unwrap<any>()),
/** 算薪前 AI 校验 */
preCheck: (batchId: string) =>
get(`/payroll2/batches/${batchId}/pre-check`).then(unwrap<any>()),
@@ -407,13 +427,13 @@ export const payrollApi = {
overtimeRecords: (params: { month?: string; employeeId?: string }) =>
get('/payroll/overtime', { params }).then(unwrap<any[]>()),
/** 保存加班费记录 */
saveOvertime: (data: any) =>
saveOvertime: (data: Record<string, unknown>) =>
post('/payroll/overtime', data).then(unwrap<any>()),
/** 更新加班费记录 */
updateOvertime: (id: string, data: any) =>
updateOvertime: (id: string, data: Record<string, unknown>) =>
put(`/payroll/overtime/${id}`, data).then(unwrap<any>()),
/** 批量导入加班工时 */
batchImportOvertime: (data: any[]) =>
batchImportOvertime: (data: Record<string, unknown>[]) =>
post('/payroll/overtime/batch', data),
/** 导入加班费到批次 */
importOvertimeToBatch: (batchId: string) =>
@@ -422,19 +442,19 @@ export const payrollApi = {
overtimeConfig: () =>
get('/payroll/overtime/config').then(unwrap<any>()),
/** 保存加班费配置 */
saveOvertimeConfig: (data: any) =>
saveOvertimeConfig: (data: Record<string, unknown>) =>
post('/payroll/overtime/config', data),
/** 工资条列表 */
payslips: (params: { month?: string; employeeId?: string }) =>
get('/payroll/payslip', { params }).then(unwrap<any[]>()),
/** 创建/更新工资条 */
savePayslip: (data: any) =>
savePayslip: (data: Record<string, unknown>) =>
post('/payroll/payslip', data).then(unwrap<any>()),
/** 删除工资条 */
removePayslip: (id: string) =>
del(`/payroll/payslip/${id}`),
/** 个税试算 */
taxPreview: (data: any) =>
taxPreview: (data: Record<string, unknown>) =>
post('/payroll/tax-preview', data).then(unwrap<any>()),
/** 薪资汇总表 */
batchSummary: (id: string) =>
@@ -470,10 +490,10 @@ export const socialInsuranceApi = {
housingConfigVersions: (city?: string) =>
get('/social/housing-config/versions', { params: city ? { city } : {} }).then(unwrap<any[]>()),
/** 创建社保配置版本 */
createConfigVersion: (data: any) =>
createConfigVersion: (data: Record<string, unknown>) =>
post('/social/config/versions', data),
/** 创建公积金配置版本 */
createHousingConfigVersion: (data: any) =>
createHousingConfigVersion: (data: Record<string, unknown>) =>
post('/social/housing-config/versions', data),
/** 社保计算 */
calculate: (base: number, city: string) =>
@@ -491,10 +511,10 @@ export const socialInsuranceApi = {
housingAdjustPreview: (configId: string) =>
get(`/social/housing-config/${configId}/adjust-preview`).then(unwrap<any>()),
/** 应用调整 */
applyAdjust: (configId: string, data: any) =>
applyAdjust: (configId: string, data: Record<string, unknown>) =>
post(`/social/config/${configId}/adjust-apply`, data),
/** 应用公积金调整 */
applyHousingAdjust: (configId: string, data: any) =>
applyHousingAdjust: (configId: string, data: Record<string, unknown>) =>
post(`/social/housing-config/${configId}/adjust-apply`, data),
/** 重置调整 */
resetAdjust: (configId: string, city: string) =>
@@ -509,19 +529,19 @@ export const socialInsuranceApi = {
monthlyProcessStatus: (month: string) =>
get('/social/monthly-process/status', { params: { month } }).then(unwrap<any>()),
/** 完成月度办理 */
completeMonthlyProcess: (data: any) =>
completeMonthlyProcess: (data: Record<string, unknown>) =>
post('/social/monthly-process/complete', data),
/** 专项附加扣除批量 */
specialDeductionBatch: (month: string) =>
get('/social/special-deduction/batch', { params: { month } }).then(unwrap<any[]>()),
/** 保存专项附加扣除 */
saveSpecialDeduction: (data: any) =>
saveSpecialDeduction: (data: Record<string, unknown>) =>
post('/social/special-deduction', data),
/** 活跃申报员工 */
activeDeclaration: (month: string) =>
get('/social/active-declaration', { params: { month } }).then(unwrap<any>()),
/** 社保/公积金记录更正 */
correctRecord: (type: 'social' | 'housing', id: string, data: any) =>
correctRecord: (type: 'social' | 'housing', id: string, data: Record<string, unknown>) =>
put(`/social/records/${type}/${id}/correct`, data).then(unwrap<any>()),
/** 社保月度变动 */
monthlyChanges: (month: string) =>
@@ -544,7 +564,7 @@ export const commercialInsuranceApi = {
enrollments: (planId: string) =>
get(`/commercial-insurance/plans/${planId}/enrollments`).then(unwrap<any[]>()),
/** 创建/更新方案 */
savePlan: (data: any, editId?: string) =>
savePlan: (data: Record<string, unknown>, editId?: string) =>
editId ? put(`/commercial-insurance/plans/${editId}`, data) : post('/commercial-insurance/plans', data),
/** 删除方案 */
removePlan: (id: string) =>
@@ -555,16 +575,16 @@ export const commercialInsuranceApi = {
export const terminationApi = {
/** 创建离职 */
create: (data: any) =>
create: (data: Record<string, unknown>) =>
post('/termination', data),
/** 创建草稿 */
createDraft: (data: any) =>
createDraft: (data: Record<string, unknown>) =>
post('/termination/draft', data),
/** 更新草稿 */
updateDraft: (draftId: string, data: any) =>
updateDraft: (draftId: string, data: Record<string, unknown>) =>
put(`/termination/draft/${draftId}`, data),
/** 草稿列表 */
drafts: (params: any) =>
drafts: (params: Record<string, unknown>) =>
get('/termination/drafts', { params }).then(unwrap<any>()),
/** 草稿详情 */
detail: (draftId: string) =>
@@ -594,7 +614,7 @@ export const terminationApi = {
assess: (employeeId: string, reason: string) =>
get(`/termination/assess/${employeeId}`, { params: { reason } }).then(unwrap<any>()),
/** 批量预览 */
batchPreview: (items: any[]) =>
batchPreview: (items: Record<string, unknown>[]) =>
post('/termination/batch/preview', { items }),
}
@@ -608,10 +628,10 @@ export const policiesApi = {
detail: (id: string) =>
get(`/policies/${id}`).then(unwrap<any>()),
/** 创建制度 */
create: (data: any) =>
create: (data: Record<string, unknown>) =>
post('/policies', data),
/** 更新制度 */
update: (id: string, data: any) =>
update: (id: string, data: Record<string, unknown>) =>
put(`/policies/${id}`, data),
/** 推进民主程序 */
advanceStep: (id: string, step: number, note?: string) =>
@@ -653,7 +673,7 @@ export const calendarApi = {
events: (month: string) =>
get(`/calendar?month=${month}`).then(unwrap<any[]>()),
/** 创建事件 */
createEvent: (data: any) =>
createEvent: (data: Record<string, unknown>) =>
post('/calendar', data),
/** 删除事件 */
removeEvent: (id: string) =>
@@ -667,7 +687,7 @@ export const companyFilesApi = {
list: (params?: { fileType?: string }) =>
get('/company-files', { params }).then(unwrap<any[]>()),
/** 添加文件 */
add: (data: any) =>
add: (data: Record<string, unknown>) =>
post('/company-files', data),
/** 删除文件 */
remove: (id: string) =>
@@ -684,7 +704,7 @@ export const notificationsApi = {
settings: () =>
get('/notifications/settings').then(unwrap<any>()),
/** 更新通知设置 */
updateSettings: (data: any) =>
updateSettings: (data: Record<string, unknown>) =>
put('/notifications/settings', data),
/** 检查合同到期 */
checkContracts: () =>
@@ -701,16 +721,16 @@ export const settingsApi = {
org: () =>
get('/settings/org').then(unwrap<any>()),
/** 更新组织设置 */
updateOrg: (data: any) =>
updateOrg: (data: Record<string, unknown>) =>
put('/settings/org', data),
/** 用户列表 */
users: () =>
get('/settings/users').then(unwrap<any>()),
/** 添加用户 */
addUser: (data: any) =>
addUser: (data: Record<string, unknown>) =>
post('/settings/users', data),
/** 更新用户 */
updateUser: (id: string, data: any) =>
updateUser: (id: string, data: Record<string, unknown>) =>
put(`/settings/users/${id}`, data),
/** 切换用户禁用状态 */
toggleDisable: (id: string) =>
@@ -739,7 +759,7 @@ export const templatesApi = {
detail: (id: string) =>
get(`/templates/${id}`).then(unwrap<any>()),
/** 渲染模板 */
render: (id: string, variables: any) =>
render: (id: string, variables: Record<string, unknown>) =>
post(`/templates/${id}/render`, { variables }).then(unwrap<any>()),
/** 企业模板列表 */
enterpriseList: (params: { page?: number; pageSize?: number; category?: string }) =>
@@ -748,13 +768,13 @@ export const templatesApi = {
enterpriseDetail: (id: string) =>
get(`/enterprise-templates/${id}`).then(unwrap<any>()),
/** 创建/更新企业模板 */
saveEnterprise: (data: any, editId?: string) =>
saveEnterprise: (data: Record<string, unknown>, editId?: string) =>
editId ? put(`/enterprise-templates/${editId}`, data) : post('/enterprise-templates', data),
/** 删除企业模板 */
removeEnterprise: (id: string) =>
del(`/enterprise-templates/${id}`),
/** 渲染企业模板 */
renderEnterprise: (id: string, variables: any) =>
renderEnterprise: (id: string, variables: Record<string, unknown>) =>
post(`/enterprise-templates/${id}/render`, { variables }).then(unwrap<any>()),
}
@@ -768,7 +788,7 @@ export const workProcessApi = {
detail: (id: string) =>
get(`/work-processes/${id}`).then(unwrap<any>()),
/** 创建 */
create: (data: any) =>
create: (data: Record<string, unknown>) =>
post('/work-processes', data).then(unwrap<any>()),
/** 提交 */
submit: (id: string) =>
@@ -794,10 +814,10 @@ export const specialStatusApi = {
stats: () =>
get('/special-statuses/stats/overview').then(unwrap<any>()),
/** 创建 */
create: (data: any) =>
create: (data: Record<string, unknown>) =>
post('/special-statuses', data),
/** 更新 */
update: (id: string, data: any) =>
update: (id: string, data: Record<string, unknown>) =>
put(`/special-statuses/${id}`, data),
/** 删除 */
remove: (id: string) =>
@@ -816,7 +836,7 @@ export const searchApi = {
export const surveyApi = {
/** 提交问卷 */
submit: (items: any[]) =>
submit: (items: Record<string, unknown>[]) =>
post('/survey/submit', { items }),
}
@@ -833,13 +853,13 @@ export const platformApi = {
orgDetail: (id: string) =>
get(`/platform/orgs/${id}`).then(unwrap<any>()),
/** 创建组织 */
createOrg: (data: any) =>
createOrg: (data: Record<string, unknown>) =>
post('/platform/orgs', data),
/** 更新组织 */
updateOrg: (id: string, data: any) =>
updateOrg: (id: string, data: Record<string, unknown>) =>
put(`/platform/orgs/${id}`, data),
/** 更新组织管理员 */
updateOrgAdmin: (id: string, data: any) =>
updateOrgAdmin: (id: string, data: Record<string, unknown>) =>
put(`/platform/orgs/${id}/admin`, data),
/** 删除组织 */
removeOrg: (id: string) =>
@@ -914,7 +934,7 @@ export const portalApi = {
onboardingInfo: (token: string) =>
portalGet(`/onboarding/${token}`).then(unwrap<any>()),
/** 入职提交 */
onboardingSubmit: (data: any) =>
onboardingSubmit: (data: Record<string, unknown>) =>
portalPost('/onboarding', data),
/** 入职上传文件 */
onboardingUpload: (token: string, formData: FormData) =>
@@ -935,9 +955,18 @@ export const portalApi = {
resignationStatus: () =>
portalGet('/resignation/status').then(unwrap<any[]>()),
/** 提交离职申请 */
resignationSubmit: (data: any) =>
resignationSubmit: (data: Record<string, unknown>) =>
portalPost('/resignation/submit', data).then(unwrap<any>()),
/** 撤回离职申请 */
resignationWithdraw: (id: string) =>
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
/** 我的休假申请列表 */
myLeaves: () =>
portalGet('/leaves').then(unwrap<any[]>()),
/** 提交休假申请 */
submitLeave: (data: Record<string, unknown>) =>
portalPost('/leaves', data).then(unwrap<any>()),
/** 撤回休假申请 */
cancelLeave: (id: string) =>
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
}
+18 -1
View File
@@ -17,6 +17,12 @@ api.interceptors.request.use((config) => {
})
let isRefreshing = false
let refreshQueue: Array<(token: string) => void> = []
function onTokenRefreshed(token: string) {
refreshQueue.forEach((cb) => cb(token))
refreshQueue = []
}
api.interceptors.response.use(
(response) => response.data,
@@ -24,7 +30,16 @@ api.interceptors.response.use(
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
if (isRefreshing) return Promise.reject(error)
if (isRefreshing) {
return new Promise((resolve) => {
refreshQueue.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(api(originalRequest))
})
})
}
isRefreshing = true
try {
const refreshToken = useAuthStore.getState().refreshToken
@@ -32,9 +47,11 @@ api.interceptors.response.use(
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken })
const newToken = res.data.data.accessToken
useAuthStore.getState().updateToken(newToken)
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
} catch {
refreshQueue = []
useAuthStore.getState().logout()
window.location.href = '/login'
return Promise.reject(error)
+1 -1
View File
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import ErrorBoundary from './components/ErrorBoundary'
import ErrorBoundary from './components/ui/ErrorBoundary'
import { ConfirmProvider } from './hooks/useConfirm'
import './index.css'
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -10,6 +10,7 @@ import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import { useConfirm } from '../hooks/useConfirm'
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
@@ -505,6 +506,7 @@ function ConfirmTab() {
// ========== 班次管理 Tab ==========
function ShiftsTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showAdd, setShowAdd] = useState(false)
const [editShift, setEditShift] = useState<any>(null)
const [form, setForm] = useState({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' })
@@ -543,6 +545,9 @@ function ShiftsTab() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end">
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
<Plus className="w-4 h-4 mr-1" />
@@ -564,7 +569,7 @@ function ShiftsTab() {
</div>
<div className="flex gap-1">
<button className="text-xs text-gray-400 hover:text-primary px-1" onClick={() => { setEditShift(s); setForm(s); setShowAdd(true) }}></button>
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(s.id) }}></button>
<button className="text-xs text-gray-400 hover:text-red-500 px-1" onClick={async () => { if (await confirm({ title: '删除班次', message: '确认删除?' })) deleteMutation.mutate(s.id) }}></button>
</div>
</div>
<div className="mt-2 text-xs text-gray-500 space-y-0.5">
@@ -685,6 +690,9 @@ function ScheduleTab() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="date"
@@ -809,6 +817,9 @@ function DailyTab() {
return (
<div className="space-y-3">
<PageGuide>
//退/
</PageGuide>
<div className="flex justify-end">
<input
type="date"
@@ -890,6 +901,9 @@ function MonthlyTab() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<input
type="month"
@@ -954,6 +968,7 @@ function MonthlyTab() {
// ========== 休假记录 Tab ==========
function LeavesTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ employeeId: '', leaveType: 'PERSONAL', startDate: '', endDate: '', days: 1, reason: '', remark: '' })
@@ -994,6 +1009,9 @@ function LeavesTab() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex justify-end">
<Button onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
@@ -1025,7 +1043,7 @@ function LeavesTab() {
</div>
</div>
</div>
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(lv.id) }}>
<button className="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" onClick={async () => { if (await confirm({ title: '删除休假记录', message: '确认删除?' })) deleteMutation.mutate(lv.id) }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ScrollText, Search, Filter } from 'lucide-react'
import { ScrollText } from 'lucide-react'
import { auditApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X, MapPin, User } from 'lucide-react'
import { CalendarDays, Plus, Trash2, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { dashboardApi, calendarApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react'
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
import Card from '../components/ui/Card'
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ShieldCheck, FileText, AlertCircle, CheckCircle, XCircle } from 'lucide-react'
import { ShieldCheck, FileText, CheckCircle, XCircle } from 'lucide-react'
import { evidenceApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
/**
* 证据链管理页面
@@ -14,7 +16,7 @@ export default function Evidence() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading } = useQuery<any>({
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['evidence', refType, page, pageSize],
queryFn: async () => {
const params: any = { page, pageSize }
@@ -35,6 +37,9 @@ export default function Evidence() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
@@ -82,6 +87,8 @@ export default function Evidence() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : !list || list.length === 0 ? (
<EmptyState title="暂无证据链记录" description="系统操作将自动生成证据链" />
) : (
+259
View File
@@ -0,0 +1,259 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { CalendarClock, Check, X, Trash2, Clock, CheckCircle2, XCircle, RotateCcw, FileText, Smartphone } from 'lucide-react'
import { leaveApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const LEAVE_TYPE_MAP: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待审批', color: 'bg-amber-50 text-warning', icon: <Clock className="w-3 h-3" /> },
APPROVED: { label: '已批准', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
}
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function LeaveApproval() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filterStatus, setFilterStatus] = useState('')
const [filterType, setFilterType] = useState('')
const [approveModal, setApproveModal] = useState<{ id: string; action: string; name: string } | null>(null)
const [approveRemark, setApproveRemark] = useState('')
const { data: stats } = useQuery<any>({
queryKey: ['leave-stats'],
queryFn: () => leaveApi.stats(),
})
const { data: result, isLoading } = useQuery<any>({
queryKey: ['leave-requests', filterStatus, filterType, page, pageSize],
queryFn: () => leaveApi.list({ status: filterStatus || undefined, leaveType: filterType || undefined, page, pageSize }),
})
const list = result?.list || []
const total = result?.total || 0
const approveMutation = useMutation({
mutationFn: ({ id, action, remark }: { id: string; action: string; remark?: string }) =>
leaveApi.approve(id, action, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('审批完成')
setApproveModal(null)
setApproveRemark('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '审批失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => leaveApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leave-requests'] })
queryClient.invalidateQueries({ queryKey: ['leave-stats'] })
toast.success('已删除')
},
})
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarClock className="w-5 h-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
</div>
{/* 流程说明 */}
<div className="bg-blue-50 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<Smartphone className="w-4 h-4 text-blue-600 flex-shrink-0" />
<span className="text-xs font-medium text-blue-700"></span>
</div>
<div className="text-xs text-blue-600 leading-relaxed">
<br/>
<br/>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"><FileText className="w-4 h-4 text-blue-600" /></div>
<div><div className="text-sm font-bold">{stats?.total || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center"><Clock className="w-4 h-4 text-amber-600" /></div>
<div><div className="text-sm font-bold text-warning">{stats?.pending || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center"><CheckCircle2 className="w-4 h-4 text-green-600" /></div>
<div><div className="text-sm font-bold text-safe">{stats?.approved || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center"><XCircle className="w-4 h-4 text-red-600" /></div>
<div><div className="text-sm font-bold text-danger">{stats?.rejected || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
<Card className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center"><RotateCcw className="w-4 h-4 text-gray-500" /></div>
<div><div className="text-sm font-bold text-gray-500">{stats?.cancelled || 0}</div><div className="text-xs text-gray-500"></div></div>
</Card>
</div>
{/* 筛选 + 操作 */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
<option value="PENDING"></option>
<option value="APPROVED"></option>
<option value="REJECTED"></option>
<option value="CANCELLED"></option>
</Select>
<Select value={filterType} onChange={(e) => { setFilterType(e.target.value); setPage(1) }} className="!w-28">
<option value=""></option>
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
{total > 0 && <span className="text-xs text-gray-500">{total} </span>}
<div className="flex-1" />
</div>
{/* 列表 */}
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : list.length === 0 ? (
<EmptyState title="暂无休假申请" description="员工在手机端提交的休假申请将显示在此处" />
) : (
<>
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
</thead>
<tbody>
{list.map((item: any) => {
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
return (
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2.5 px-3">
<div className="text-xs font-medium">{item.employee?.name}</div>
<div className="text-xs text-gray-500">{item.employee?.department}</div>
</td>
<td className="py-2.5 px-3 text-xs">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</td>
<td className="py-2.5 px-3 text-xs text-gray-600">
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
</td>
<td className="py-2.5 px-3 text-right text-sm font-medium">{item.days}</td>
<td className="py-2.5 px-3 text-xs text-gray-600 max-w-32 truncate" title={item.reason || ''}>{item.reason || '—'}</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
</td>
<td className="py-2.5 px-3 text-xs text-gray-500 max-w-32 truncate" title={item.approveRemark || ''}>{item.approveRemark || '—'}</td>
<td className="py-2.5 px-3">
<div className="flex items-center justify-end gap-1">
{item.status === 'PENDING' && (
<>
<button
onClick={() => setApproveModal({ id: item.id, action: 'APPROVED', name: item.employee?.name })}
className="p-1 rounded hover:bg-green-50 text-gray-500 hover:text-safe transition-colors"
title="批准"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setApproveModal({ id: item.id, action: 'REJECTED', name: item.employee?.name })}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="驳回"
>
<X className="w-3.5 h-3.5" />
</button>
<button
onClick={async () => {
if (await confirm({ title: '删除申请', message: '确认删除该休假申请?此操作不可撤销。' })) {
deleteMutation.mutate(item.id)
}
}}
className="p-1 rounded hover:bg-red-50 text-gray-500 hover:text-danger transition-colors"
title="删除"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
</>
)}
{/* 审批弹窗 */}
{approveModal && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setApproveModal(null)}>
<Card className="max-w-md w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">
{approveModal.action === 'APPROVED' ? '批准休假申请' : '驳回休假申请'} {approveModal.name}
</h3>
<button onClick={() => setApproveModal(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={approveRemark} onChange={(e) => setApproveRemark(e.target.value)} placeholder="请输入审批意见(可选)" />
</div>
<div className="flex gap-2">
<Button
variant={approveModal.action === 'APPROVED' ? 'primary' : 'danger'}
onClick={() => approveMutation.mutate({ id: approveModal.id, action: approveModal.action, remark: approveRemark })}
disabled={approveMutation.isPending}
>
{approveMutation.isPending ? '处理中...' : approveModal.action === 'APPROVED' ? '确认批准' : '确认驳回'}
</Button>
<Button variant="secondary" onClick={() => setApproveModal(null)}></Button>
</div>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -155,7 +155,6 @@ export default function Notifications() {
}
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
const queryClient = useQueryClient()
const [form, setForm] = useState({
contractExpiry: settings.contractExpiry ?? true,
expiryDays: settings.expiryDays ?? 30,
+8 -1
View File
@@ -7,6 +7,8 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
const STEP_LABELS: Record<string, string> = {
DRAFTING: '起草',
@@ -27,7 +29,7 @@ export default function Policies() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading } = useQuery<any>({
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['policies', page, pageSize],
queryFn: async () => {
return await policiesApi.list({ page, pageSize })
@@ -51,6 +53,9 @@ export default function Policies() {
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
@@ -66,6 +71,8 @@ export default function Policies() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : !list || list.length === 0 ? (
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
) : (
+10 -3
View File
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet, Download } from 'lucide-react'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
@@ -12,11 +12,13 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
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 { fmt, terminateReasonMap } from './roster/shared'
import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
export default function Roster() {
const queryClient = useQueryClient()
@@ -49,7 +51,7 @@ export default function Roster() {
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
const { data: rosterData, isLoading } = useQuery<any>({
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
queryFn: async () => {
const params: any = { page, pageSize }
@@ -272,6 +274,9 @@ export default function Roster() {
return (
<div className="space-y-5">
<PageGuide>
</PageGuide>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="flex items-center gap-2">
@@ -407,6 +412,8 @@ export default function Roster() {
{isLoading ? (
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : filtered.length === 0 ? (
<Card><div className="py-12 text-center text-sm text-gray-400"></div></Card>
) : (
+8 -1
View File
@@ -9,6 +9,8 @@ import {
import Card from '../components/ui/Card'
import { Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
import { salaryDashboardApi } from '../lib/api-services'
/** 金额格式化 */
@@ -18,7 +20,7 @@ export default function SalaryDashboard() {
const [year, setYear] = useState(new Date().getFullYear().toString())
/** 获取薪酬分析数据 */
const { data, isLoading } = useQuery<any>({
const { data, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['salary-dashboard', year],
queryFn: async () => {
return await salaryDashboardApi.data(Number(year))
@@ -37,6 +39,9 @@ export default function SalaryDashboard() {
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -55,6 +60,8 @@ export default function SalaryDashboard() {
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : !data ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
+32 -2
View File
@@ -8,6 +8,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
@@ -618,6 +619,7 @@ function handleAcceptanceExport() {
function PlanSettings({ orgData }: { orgData: any }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const plan = orgData?.plan || 'FREE'
const { data: usageData } = useQuery<any>({
@@ -683,8 +685,8 @@ function PlanSettings({ orgData }: { orgData: any }) {
variant="secondary"
className="w-full"
size="sm"
onClick={() => {
if (confirm(`确定切换到${p.label}`)) planMutation.mutate(p.key)
onClick={async () => {
if (await confirm({ title: '切换套餐', message: `确定切换到${p.label}`, variant: 'primary' })) planMutation.mutate(p.key)
}}
disabled={planMutation.isPending}
>
@@ -959,8 +961,14 @@ function InitImport() {
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
toast.error(data.error?.message || '导入失败')
} else {
setResult(data.data)
if (data.data.errors?.length > 0) {
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
} else {
toast.success(`成功导入员工 ${data.data.employees}`)
}
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
@@ -1123,6 +1131,22 @@ function InitImport() {
{result.errors.length > 10 && <div className="text-amber-600">... {result.errors.length - 10} </div>}
</div>
)}
{/* 导入明细 */}
{result.details?.filter((d: any) => d.status === 'success').length > 0 && (
<div className="mt-2 pt-2 border-t border-green-200">
<div className="font-medium text-gray-600 mb-1"></div>
<div className="max-h-40 overflow-y-auto">
{result.details.filter((d: any) => d.status === 'success').map((d: any, i: number) => (
<div key={i} className="flex gap-3 text-xs text-gray-500">
<span>{d.row}</span>
<span>{d.name}</span>
{d.employeeId && <span className="text-gray-400">ID: {d.employeeId.slice(0, 8)}...</span>}
<span className="text-safe"> {d.message}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
@@ -1175,8 +1199,14 @@ function MonthlyImport() {
const data = await res.json()
if (!data.success) {
setError(data.error?.message || '导入失败')
toast.error(data.error?.message || '导入失败')
} else {
setResult(data.data)
if (data.data.errors?.length > 0) {
toast.warning(`导入完成,但有 ${data.data.errors.length} 条错误,请查看详情`)
} else {
toast.success('月度数据导入成功')
}
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
}
+24 -749
View File
@@ -1,14 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect } 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, Shield } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
import { InlineAlert } from '../components/ui/InlineAlert'
import { socialInsuranceApi, commercialInsuranceApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import PageGuide from '../components/ui/PageGuide'
import { socialInsuranceApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
import CommercialInsuranceTab from './social-insurance/CommercialInsuranceTab'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -470,6 +473,10 @@ export default function SocialInsurance() {
</Button>
</div>
</div>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
<Info className="w-4 h-4 shrink-0" />
<span>7 /</span>
</div>
{isHousing ? (
<>
{(housingAllAccounts || []).length > 1 && (
@@ -899,8 +906,20 @@ export default function SocialInsurance() {
</div>
)}
{/* 基数说明(仅社保/公积金Tab显示) */}
{(tab === 'social' || tab === 'housing') && (
<p className="text-sm text-gray-400">
/7
</p>
)}
{/* ========== 月度办理 Tab ========== */}
{tab === 'monthly' && (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
@@ -1121,6 +1140,7 @@ export default function SocialInsurance() {
)
})()}
</Card>
</div>
)}
{/* ========== 专项附加扣除 Tab ========== */}
@@ -1133,752 +1153,7 @@ export default function SocialInsurance() {
<CommercialInsuranceTab />
)}
<p className="text-sm text-gray-400">
/7
</p>
</div>
)
}
/** 月度办理社保行组件(可展开查看各险种明细,支持修改基数) */
function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('social', i.recordId, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<>
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">
{editing ? (
<span onClick={(e) => e.stopPropagation()} className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={(e) => { e.stopPropagation(); setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
{expanded && d && (
<tr className="bg-gray-50/50">
<td colSpan={8} className="py-2 px-8">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-400">
<th className="py-1 text-left"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
</tr>
</thead>
<tbody>
{d.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1">{item.name}</td>
<td className="py-1 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1 text-right text-gray-500">{item.empRate > 0 ? `${item.empRate}%` : '-'}</td>
<td className="py-1 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1 text-right">{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
/** 月度办理公积金行组件(支持修改基数) */
function MonthlyHousingRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('housing', i.recordId, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<tr className="border-b last:border-0 hover:bg-gray-50">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">
{editing ? (
<span className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => { setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
)
}
/** 专项附加扣除按月录入组件 */
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<string | null>(null)
const [editForm, setEditForm] = useState<any>(null)
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// 查询当月所有员工的专项附加扣除
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['special-deduction', month],
queryFn: async () => {
return await socialInsuranceApi.specialDeductionBatch(month)
},
})
// 查询当月社保在保人员(只有缴纳社保的员工才需要填报专项附加扣除)
const { data: employees = [] } = useQuery<any[]>({
queryKey: ['active-social-employees', month],
queryFn: async () => {
const res = await socialInsuranceApi.activeDeclaration(month) as any
return (res?.items || []).map((item: any) => ({
id: item.employeeId,
name: item.name,
department: item.department,
}))
},
})
// 查询上月专项附加扣除数据(用于「复制上月」功能)
const prevMonth = (() => {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
const { data: prevRecords = [] } = useQuery<any[]>({
queryKey: ['special-deduction', prevMonth],
queryFn: async () => {
return await socialInsuranceApi.specialDeductionBatch(prevMonth)
},
})
const prevRecordMap = new Map(prevRecords.map((r: any) => [r.employeeId, r]))
const saveMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.saveSpecialDeduction({ ...data, month }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
setEditing(null)
setEditForm(null)
},
})
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
const startEdit = (empId: string, existing?: any) => {
setEditing(empId)
setEditForm(existing ? {
children: existing.children,
elderly: existing.elderly,
housing: existing.housing,
education: existing.education,
infant: existing.infant,
remark: existing.remark,
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
}
/** 批量复制上月数据到当月 */
const batchCopyMutation = useMutation({
mutationFn: async () => {
let copied = 0
for (const prev of prevRecords) {
await socialInsuranceApi.saveSpecialDeduction({
employeeId: prev.employeeId,
month,
children: prev.children || 0,
elderly: prev.elderly || 0,
housing: prev.housing || 0,
education: prev.education || 0,
infant: prev.infant || 0,
remark: prev.remark || '',
})
copied++
}
return copied
},
onSuccess: (copied: number) => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
if (copied > 0) {
toast.success(`已复制 ${copied}${prevMonth} 的扣除数据到 ${month}`)
} else {
toast.info(`${prevMonth} 无可复制的扣除数据`)
}
},
onError: () => toast.error('复制上月数据失败'),
})
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
return (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => batchCopyMutation.mutate()}
disabled={batchCopyMutation.isPending || prevRecords.length === 0}
>
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setShowImport(true)}
>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : (
<div className="space-y-3">
{/* 已录入列表 */}
{records.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
{editing === r.employeeId ? (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
<td className="py-1">
<div className="flex gap-1">
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</td>
</>
) : (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}></button></td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 未录入员工 */}
{unrecorded.length > 0 && (
<div className="border-t pt-3">
<h3 className="text-xs font-medium text-gray-500 mb-2">{unrecorded.length}</h3>
<div className="flex flex-wrap gap-2">
{unrecorded.map((e: any) => (
<button
key={e.id}
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
onClick={() => startEdit(e.id)}
>
{e.name}{e.department}
</button>
))}
</div>
</div>
)}
{/* 新增/编辑表单 */}
{editing && !recordMap.has(editing) && (
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
<h3 className="text-xs font-medium"> {employees.find((e: any) => e.id === editing)?.name}</h3>
<div className="grid grid-cols-5 gap-2">
<div><Label></Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1"><Label></Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
<div className="text-sm text-gray-500 pt-5"><span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</div>
)}
{records.length === 0 && unrecorded.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
)}
{/* 批量导入弹窗 */}
{showImport && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/special-deduction/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '专项附加扣除导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
}}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
{importFile ? importFile.name : '点击选择 Excel 文件'}
</label>
</div>
{importResult && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div> {importResult.updated} {importResult.skipped} {importResult.total} </div>
{importResult.errors?.length > 0 && (
<div className="mt-1 pt-1 border-t border-green-200">
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}></Button>
<Button size="sm" onClick={async () => {
if (!importFile) return toast.error('请选择文件')
setImporting(true)
setImportResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', importFile)
formData.append('month', month)
const res = await fetch('/api/v1/import/special-deduction', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) { toast.error(data.error?.message || '导入失败') }
else {
setImportResult(data.data)
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
toast.success(`导入完成:成功 ${data.data.updated}`)
}
} catch (e: any) { toast.error(e?.message || '导入失败') }
finally { setImporting(false) }
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</Card>
)
}
/**
* 商险管理 Tab — 管理商业保险(意外险、补充医疗、雇主责任险等)
* 支持查看商险方案、参保人员、保单信息
*/
function CommercialInsuranceTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showAddPlan, setShowAddPlan] = useState(false)
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({
name: '',
type: 'ACCIDENT',
provider: '',
policyNo: '',
premium: 0,
coverageAmount: 0,
effectiveFrom: new Date().toISOString().slice(0, 10),
effectiveTo: '',
description: '',
})
/** 商险类型映射 */
const INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
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<any[]>({
queryKey: ['commercial-insurance-plans'],
queryFn: async () => {
return await commercialInsuranceApi.plans()
},
})
/** 获取选中方案的参保人员 */
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await commercialInsuranceApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
/** 创建/更新商险方案 */
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return commercialInsuranceApi.savePlan(data, editingPlan.id) as any
}
return commercialInsuranceApi.savePlan(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) => commercialInsuranceApi.removePlan(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 <Card><div className="text-center py-8 text-gray-400">...</div></Card>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium"></h2>
</div>
<Button size="sm" onClick={() => { setEditingPlan(null); setNewPlan({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<InlineAlert type="info">
</InlineAlert>
{/* 商险方案列表 */}
{plans.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan: any) => {
const typeCfg = INSURANCE_TYPES[plan.type] || INSURANCE_TYPES.OTHER
const isSelected = selectedPlanId === plan.id
return (
<Card
key={plan.id}
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
>
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
<div className="flex items-start justify-between mb-2">
<div>
<span className={`px-2 py-0.5 rounded text-xs ${typeCfg.color}`}>{typeCfg.label}</span>
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认删除', message: `确定删除商险方案「${plan.name}」吗?` })) {
deletePlanMutation.mutate(plan.id)
}
}}>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.provider}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700 font-mono">{plan.policyNo || '—'}</span></div>
<div className="flex justify-between"><span>()</span><span className="text-gray-700">¥{fmt(plan.premium)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.coverageAmount)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.effectiveFrom} ~ {plan.effectiveTo || '长期'}</span></div>
</div>
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
</div>
</Card>
)
})}
</div>
)}
{/* 参保人员列表 */}
{selectedPlanId && (
<Card>
<h3 className="text-sm font-medium mb-3">{enrollments.length}</h3>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2 text-gray-400 font-mono text-xs">{e.idCardMasked || '—'}</td>
<td className="py-2 text-right">¥{fmt(e.premium || 0)}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '有效' : '已终止'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新增/编辑方案弹窗 */}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{editingPlan ? '编辑商险方案' : '新增商险方案'}</h3>
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度员工意外险" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.type}
onChange={(e) => setNewPlan({ ...newPlan, type: e.target.value })}
>
{Object.entries(INSURANCE_TYPES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label> *</Label>
<Input value={newPlan.provider} onChange={(e) => setNewPlan({ ...newPlan, provider: e.target.value })} placeholder="如:中国人寿" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={newPlan.policyNo} onChange={(e) => setNewPlan({ ...newPlan, policyNo: e.target.value })} placeholder="保单编号" />
</div>
<div>
<Label>/</Label>
<Input type="number" value={newPlan.premium} onChange={(e) => setNewPlan({ ...newPlan, premium: parseFloat(e.target.value) || 0 })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.coverageAmount} onChange={(e) => setNewPlan({ ...newPlan, coverageAmount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveTo} onChange={(e) => setNewPlan({ ...newPlan, effectiveTo: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveFrom} onChange={(e) => setNewPlan({ ...newPlan, effectiveFrom: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={newPlan.description} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="保障范围、免赔额等" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
{savePlanMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+15 -3
View File
@@ -4,9 +4,12 @@
*/
import { useEffect, useState } from 'react'
import { Search, Plus, Edit2, Trash2, AlertTriangle, Clock, Baby, HeartPulse, Activity, X } from 'lucide-react'
import { toast } from 'sonner'
import { specialStatusApi, employeeApi } from '../lib/api-services'
import { Input, Select, Label } from '../components/ui/Input'
import Button from '../components/ui/Button'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
interface SpecialStatus {
id: string
@@ -105,6 +108,7 @@ export default function SpecialStatus() {
const [typeFilter, setTypeFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState<any>(null)
const [stats, setStats] = useState<Stats | null>(null)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<SpecialStatus | null>(null)
@@ -135,6 +139,7 @@ export default function SpecialStatus() {
const fetchList = async () => {
setLoading(true)
setFetchError(null)
try {
const params: any = { page, pageSize }
if (search) params.search = search
@@ -143,6 +148,8 @@ export default function SpecialStatus() {
const res = await specialStatusApi.list({ page, pageSize, search: search || undefined, type: typeFilter || undefined, status: statusFilter || undefined } as any) as any
setList(res.list)
setTotal(res.total)
} catch (err: any) {
setFetchError(err)
} finally {
setLoading(false)
}
@@ -201,7 +208,7 @@ export default function SpecialStatus() {
}
const handleSave = async () => {
if (!form.employeeId) { alert('请选择员工'); return }
if (!form.employeeId) { toast.error('请选择员工'); return }
try {
const data: any = { ...form }
// 空字符串转 null
@@ -220,7 +227,7 @@ export default function SpecialStatus() {
fetchList()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.error?.message || '保存失败')
toast.error(err.response?.data?.error?.message || '保存失败')
}
}
@@ -232,7 +239,7 @@ export default function SpecialStatus() {
fetchList()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.error?.message || '删除失败')
toast.error(err.response?.data?.error?.message || '删除失败')
}
}
@@ -240,6 +247,9 @@ export default function SpecialStatus() {
return (
<div className="space-y-6">
<PageGuide>
//
</PageGuide>
{/* 标题 */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
@@ -312,6 +322,8 @@ export default function SpecialStatus() {
{/* 列表 */}
{loading ? (
<div className="text-center py-12 text-gray-400">...</div>
) : fetchError ? (
<QueryError error={fetchError} onRetry={fetchList} />
) : list.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<AlertTriangle className="w-12 h-12 mx-auto mb-3 text-gray-300" />
+4 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileText, Copy, X, ChevronRight, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { FileText, Copy, X, Download, BookOpen, HelpCircle, Plus, Edit, Trash2, Building2 } from 'lucide-react'
import { toast } from 'sonner'
import { templatesApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
@@ -10,6 +10,7 @@ import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import { useConfirm } from '../hooks/useConfirm'
const CATEGORY_LABELS: Record<string, string> = {
CONTRACT: '合同',
@@ -300,6 +301,7 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [category, setCategory] = useState<string>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
@@ -448,7 +450,7 @@ function EnterpriseTemplates() {
<Edit className="w-3 h-3" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMutation.mutate(t.id) }}
onClick={async () => { if (await confirm({ title: '删除模板', message: '确认删除?' })) deleteMutation.mutate(t.id) }}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-red-600"
>
<Trash2 className="w-3 h-3" />
+63 -76
View File
@@ -4,6 +4,7 @@ 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 PageGuide from '../components/ui/PageGuide'
import jsPDF from 'jspdf'
import { rosterApi, terminationApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
@@ -12,6 +13,7 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import { useConfirm } from '../hooks/useConfirm'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -139,19 +141,6 @@ const DEFAULT_HANDOVER_ITEMS = [
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
]
interface RosterEmployee {
id: string
name: string
department: string
status: string
hasTermination?: boolean
latestTerminationStatus?: string
hireDate: string
monthlySalary: number
latestContract: any
counts: any
}
interface EmployeeProfile {
id: string
name: string
@@ -171,6 +160,7 @@ interface EmployeeProfile {
export default function Termination() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
const [draftId, setDraftId] = useState<string | null>(null)
const [step, setStep] = useState(0)
@@ -182,7 +172,7 @@ export default function Termination() {
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
const [socialAvgWage, setSocialAvgWage] = useState(0)
const [compBreakdown, setCompBreakdown] = useState<any>(null)
const [, setCompBreakdown] = useState<any>(null)
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
@@ -341,7 +331,7 @@ export default function Termination() {
})
// 草稿列表
const { data: draftsData, refetch: refetchDrafts } = useQuery({
const { data: draftsData } = useQuery({
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm, draftPage, draftPageSize],
queryFn: async () => {
const params: any = { page: draftPage, pageSize: draftPageSize }
@@ -524,28 +514,37 @@ export default function Termination() {
}
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
// 计算调整后的实际补偿金(系统预估 + 手动调整差额)
const adjustedTotal = useMemo(() => {
if (!costResult) return 0
let total = costResult.grandTotal
compAdjustments.forEach(adj => {
total += (adj.to - adj.from)
})
return total
}, [costResult, compAdjustments])
const canProceed = () => {
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
if (step === 2) return true
// step 2: 合规检查 — required 项必须勾选
if (step === 2) {
if (!checklistItems) return true
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
if (requiredItems.length === 0) return true
return requiredItems.every(item => checklist[item.key] === true)
}
// step 3: 费用结算 — 有补偿金时需确认
if (step === 3) return true
if (step === 4) return true
// step 4: 工作交接 — 关键项需完成
if (step === 4) {
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
if (keyItems.length === 0) return true
return keyItems.every(item => item.done)
}
return false
}
const handleSave = () => {
saveMutation.mutate({
employeeId,
reason,
terminationDate: new Date(terminationDate).toISOString(),
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
compensation: costResult?.totalSeverance || 0,
checklist,
remark: '',
})
}
/** 保存草稿(任意步骤可调用) */
const handleSaveDraft = () => {
const breakdown = costResult ? {
@@ -553,7 +552,8 @@ export default function Termination() {
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
other: 0,
total: costResult.grandTotal,
total: adjustedTotal,
systemTotal: costResult.grandTotal,
adjustments: compAdjustments,
} : null
@@ -564,7 +564,7 @@ export default function Termination() {
terminationDate: terminationDate ? new Date(terminationDate).toISOString() : new Date().toISOString(),
socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7),
housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7),
compensation: costResult?.grandTotal || 0,
compensation: adjustedTotal || 0,
checklist,
currentStep: step,
compensationBreakdown: breakdown,
@@ -610,41 +610,6 @@ export default function Termination() {
toast.success('已调整')
}
// 模拟计算:追加新版本,支持参数对比
const handleSimulate = () => {
if (!costResult || !selectedEmployee) return
setSavedItems((prev) => {
// 该员工的最新版本号
const sameEmployee = prev.filter(item => item.employeeId === employeeId)
const maxVersion = sameEmployee.reduce((max, item) => Math.max(max, item.version), 0)
const newVersion = maxVersion + 1
// 追加新版本(不覆盖旧版本)
return [
...prev,
{
id: `sim-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
employeeId,
name: selectedEmployee.name,
department: selectedEmployee.department,
reason,
reasonLabel,
terminationDate,
severancePay: costResult.severancePay,
noticePay: costResult.noticePay,
doublePay: costResult.doublePay,
grandTotal: costResult.grandTotal,
years: costResult.years,
remainingMonths: costResult.remainingMonths,
compMonths: costResult.compMonths,
version: newVersion,
isSimulated: true,
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
},
]
})
handleReset()
}
// 保存成功后追加正式版本(isSimulated=false
useEffect(() => {
if (saveMutation.isSuccess && costResult && selectedEmployee) {
@@ -731,6 +696,9 @@ export default function Termination() {
{/* 草稿列表视图 */}
{view === 'list' && (
<>
<PageGuide>
稿
</PageGuide>
<div className="flex gap-2 flex-wrap items-center">
<Input
placeholder="搜索员工姓名或部门"
@@ -844,8 +812,8 @@ export default function Termination() {
)}
{item.status === 'DRAFT' && (
<button
onClick={() => {
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
onClick={async () => {
if (await confirm({ title: '执行解聘', message: `确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。` })) {
setDraftId(item.id)
executeMutation.mutate()
}
@@ -1548,12 +1516,23 @@ export default function Termination() {
<div>{socialInsEndMonth || terminationDate.slice(0, 7)}</div>
<div>{housingFundEndMonth || terminationDate.slice(0, 7)}</div>
{costResult && (
<div>¥{fmt(costResult.grandTotal)}</div>
<>
{compAdjustments.length > 0 ? (
<>
<div>¥{fmt(costResult.grandTotal)}</div>
{compAdjustments.map((adj, i) => (
<div key={i} className="text-amber-700">
- {adj.field}¥{fmt(adj.from)} ¥{fmt(adj.to)}{adj.reason}
</div>
))}
<div className="font-medium text-primary">¥{fmt(adjustedTotal)}</div>
</>
) : (
<div>¥{fmt(costResult.grandTotal)}</div>
)}
</>
)}
<div>{handoverItems.filter(i => i.done).length}/{handoverItems.length} </div>
{compAdjustments.length > 0 && (
<div className="text-amber-700"> {compAdjustments.length} </div>
)}
</div>
{/* 风险提示 */}
@@ -1771,9 +1750,17 @@ export default function Termination() {
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
{step < 4 ? (
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
<div className="flex items-center gap-2">
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
<span className="text-xs text-danger"></span>
)}
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
<span className="text-xs text-danger"></span>
)}
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
) : step === 4 ? (
<div className="flex gap-2">
<Button variant="secondary" onClick={handleSaveDraft} disabled={saveDraftMutation.isPending}>
+46 -5
View File
@@ -6,12 +6,14 @@ import {
Repeat, Pause, FileText, XCircle, UserX, FileMinus, Briefcase,
Loader2, ChevronRight, Trash2, Send, X, Eye,
} from 'lucide-react'
import { workProcessApi } from '../lib/api-services'
import { workProcessApi, templatesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
@@ -48,7 +50,7 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
}
// 各流程类型的表单字段配置
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea'; options?: string[] }[]> = {
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template'; options?: string[] }[]> = {
HIRE: [
{ key: 'name', label: '员工姓名', type: 'text' },
{ key: 'department', label: '部门', type: 'text' },
@@ -99,6 +101,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'suspendDate', label: '中止日期', type: 'date' },
],
INCOME_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
@@ -120,6 +123,7 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
{ key: 'compensation', label: '经济补偿金', type: 'number' },
],
LEAVING_CERT: [
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
{ key: 'employeeName', label: '员工姓名', type: 'text' },
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
{ key: 'position', label: '职务', type: 'text' },
@@ -137,6 +141,12 @@ const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | '
],
}
// 字段 key → 中文 label 映射(用于展示已保存的表单数据)
const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat().reduce((acc, f) => {
acc[f.key] = f.label
return acc
}, {} as Record<string, string>)
export default function WorkProcess() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
@@ -149,7 +159,7 @@ export default function WorkProcess() {
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading } = useQuery({
const { data: listData, isLoading, isError, error, refetch } = useQuery({
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
const params: any = { page, pageSize }
@@ -240,6 +250,9 @@ export default function WorkProcess() {
return (
<div className="space-y-4">
<PageGuide>
</PageGuide>
{/* 发起办理 */}
<Card>
<div className="flex items-center justify-between mb-4">
@@ -294,6 +307,8 @@ export default function WorkProcess() {
{isLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : items.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400"></div>
) : (
@@ -371,6 +386,8 @@ export default function WorkProcess() {
value={formData[field.key] || ''}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
) : field.type === 'enterprise-template' ? (
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
@@ -452,8 +469,8 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
<div className="bg-gray-50 rounded-md p-3 space-y-1">
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
<div key={key} className="flex text-xs">
<span className="text-gray-500 w-28 shrink-0">{key}</span>
<span className="text-gray-900">{String(value)}</span>
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
</div>
))}
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400"></span>}
@@ -508,3 +525,27 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
</div>
)
}
function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { data, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates-for-cert'],
queryFn: async () => {
const res = await templatesApi.enterpriseList({ pageSize: 100 } as any)
return res
},
})
const items = data?.items || []
return (
<div>
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={isLoading}>
<option value="">{isLoading ? '加载中...' : '使用系统默认模板'}</option>
{items.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</Select>
{items.length === 0 && !isLoading && (
<p className="text-xs text-gray-400 mt-1"></p>
)}
</div>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Scale, Loader2, Plus, Trash2, Save, History } from 'lucide-react'
import { aiApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
// 通用 AI 历史记录 hook
function useAIHistory(type: 'predict' | 'review' | 'case') {
const queryClient = useQueryClient()
const queryKey = [`ai-history-${type}`]
const { data: history } = useQuery<any[]>({
queryKey,
queryFn: async () => {
return await aiApi.conversations(type)
},
})
const saveMutation = useMutation({
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
const res = await aiApi.createConversation({
title: `${type}:${title}`,
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
}) as any
return res
},
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => aiApi.removeConversation(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const loadHistory = useCallback(async (id: string) => {
return await aiApi.conversation(id)
}, [])
return { history, saveMutation, deleteMutation, loadHistory }
}
// 通用历史记录栏组件
function HistoryBar({ history, onLoad, onDelete }: {
history: any[]
onLoad: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{history.length > 0 ? history.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => onLoad(c.id)}>
{c.title.replace(/^(predict:|review:|case:)/, '')}
</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)
}
export function CaseTab() {
const [scenario, setScenario] = useState('')
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [showSaveModal, setShowSaveModal] = useState(false)
const [saveEmployeeId, setSaveEmployeeId] = useState('')
const [showHistory, setShowHistory] = useState(false)
const [showTodoModal, setShowTodoModal] = useState(false)
const [todoEmployeeId, setTodoEmployeeId] = useState('')
const [todoTitle, setTodoTitle] = useState('')
const [todoLevel, setTodoLevel] = useState('MEDIUM')
const [todoType, setTodoType] = useState('TERMINATION')
const [creatingTodo, setCreatingTodo] = useState(false)
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('case')
const { data: employees } = useQuery<any[]>({
queryKey: ['employee-list'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const handleMatch = async () => {
if (!scenario.trim()) return
setLoading(true)
setResult('')
try {
const res = await aiApi.matchCase(scenario) as any
setResult(res.result)
// 自动保存到历史
if (res?.result && !res.result.startsWith('出错了')) {
const title = scenario.slice(0, 30).replace(/\n/g, ' ')
saveMutation.mutate({ title, input: scenario, result: res.data.result })
}
} catch (err: any) {
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
const handleLoadHistory = async (id: string) => {
const data = await loadHistory(id)
if (data?.messages) {
const userMsg = data.messages.find((m: any) => m.role === 'user')
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
if (userMsg) setScenario(userMsg.content)
if (assistantMsg) setResult(assistantMsg.content)
setShowHistory(false)
}
}
const handleSave = async () => {
if (!saveEmployeeId || !result) return
try {
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
setShowSaveModal(false)
setSaveEmployeeId('')
toast.success('已保存到员工档案')
} catch (err: any) {
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
}
}
const handleCreateTodo = async () => {
if (!todoEmployeeId || !todoTitle) return
setCreatingTodo(true)
try {
await aiApi.caseToTodo({
employeeId: todoEmployeeId,
title: todoTitle,
description: result.slice(0, 500),
level: todoLevel,
type: todoType,
})
setShowTodoModal(false)
setTodoEmployeeId('')
setTodoTitle('')
toast.success('已创建待办风险项')
} catch (err: any) {
toast.error('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
} finally {
setCreatingTodo(false)
}
}
return (
<div className="space-y-4">
<Card>
<div className="flex items-center gap-2 mb-4">
<Scale className="w-5 h-5 text-primary" />
<h2 className="text-sm font-medium"></h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
{showHistory && (
<div className="mt-2 mb-3">
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
</div>
)}
<div className="mt-3">
<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-xs min-h-[150px] resize-y"
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
value={scenario}
onChange={(e) => setScenario(e.target.value)}
/>
<div className="mt-3">
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />...</> : '分析'}
</Button>
</div>
</div>
</Card>
{result && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" /></Button>
</div>
</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
{showSaveModal && (
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
<div className="space-y-3">
<h3 className="font-medium"></h3>
<Label></Label>
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}></Button>
</div>
</div>
</Modal>
)}
{showTodoModal && (
<Modal open onClose={() => setShowTodoModal(false)}>
<div className="space-y-3">
<h3 className="font-medium"></h3>
<div>
<Label></Label>
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
<option value="LOW"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
<option value="CONTRACT"></option>
<option value="SALARY"></option>
<option value="TERMINATION"></option>
<option value="MONTHLY"></option>
<option value="ONBOARDING"></option>
</Select>
</div>
</div>
<div className="text-xs text-gray-400"></div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}></Button>
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
{creatingTodo ? '创建中...' : '创建待办'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+401
View File
@@ -0,0 +1,401 @@
import { useState, useRef, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Send, Loader2, Mic, Plus, MessageSquare, Trash2, UserCheck } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { aiApi } from '../../lib/api-services'
import { useAuthStore } from '../../store/authStore'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
// 通用 AI 历史记录 hook
// 通用历史记录栏组件
interface Message {
role: 'user' | 'assistant'
content: string
}
const QUICK_QUESTIONS = [
'员工入职没签合同怎么办?',
'加班费怎么算?',
'辞退员工需要赔多少?',
'试用期最长可以约定几个月?',
]
export function ChatTab() {
const queryClient = useQueryClient()
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' },
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [recording, setRecording] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [currentConvId, setCurrentConvId] = useState<string | null>(null)
const [showConsultModal, setShowConsultModal] = useState(false)
const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' })
const scrollRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<any>(null)
const saveTimerRef = useRef<any>(null)
const { data: conversations } = useQuery<any[]>({
queryKey: ['ai-conversations'],
queryFn: async () => {
return await aiApi.conversations('chat')
},
})
const deleteConvMutation = useMutation({
mutationFn: (id: string) => aiApi.removeConversation(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
})
const consultMutation = useMutation({
mutationFn: async (data: typeof consultForm) => {
return await aiApi.consult(data)
},
onSuccess: () => {
toast.success('已提交咨询请求,专业律师将尽快与您联系')
setShowConsultModal(false)
setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' })
},
onError: (err: any) => {
toast.error(err?.message || '提交失败,请稍后重试')
},
})
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
// 自动保存会话(debounce
useEffect(() => {
if (messages.length <= 1) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}`
if (currentConvId) {
await aiApi.updateConversation(currentConvId, { messages }).catch(() => {})
} else {
const res = await aiApi.createConversation({ title, messages }) as any
if (res?.id) {
setCurrentConvId(res.id)
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
}
}
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [messages])
const loadConversation = async (id: string) => {
try {
const res = await aiApi.conversation(id) as any
if (res?.messages) {
setMessages(res.messages)
setCurrentConvId(id)
setShowHistory(false)
}
} catch {}
}
const newConversation = () => {
setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }])
setCurrentConvId(null)
setShowHistory(false)
}
const toggleVoice = () => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
return
}
if (recording) {
recognitionRef.current?.stop()
setRecording(false)
return
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = false
recognition.onresult = (event: any) => {
const transcript = event.results[0]?.[0]?.transcript || ''
setInput((prev) => prev + transcript)
}
recognition.onerror = () => setRecording(false)
recognition.onend = () => setRecording(false)
recognition.start()
recognitionRef.current = recognition
setRecording(true)
}
const send = async (text?: string) => {
const content = text || input.trim()
if (!content || loading) return
const newMessages = [...messages, { role: 'user' as const, content }]
setMessages([...newMessages, { role: 'assistant', content: '' }])
setInput('')
setLoading(true)
try {
const token = useAuthStore.getState().accessToken
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 60 * 1000)
const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream'
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
let rafId: number | null = null
let pendingFlush = false
// 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染
const flush = () => {
pendingFlush = false
rafId = null
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
}
const scheduleFlush = () => {
if (!pendingFlush) {
pendingFlush = true
rafId = requestAnimationFrame(flush)
}
}
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
scheduleFlush()
}
if (parsed.error) {
throw new Error(parsed.error)
}
} catch (parseErr: any) {
// 只有业务错误(有 message 且不是 SyntaxError)才抛出
if (parseErr instanceof SyntaxError) {
// JSON 解析失败,可能是 SSE 分块截断,跳过等下一块
continue
}
throw parseErr
}
}
}
}
// 确保最后一批内容被刷新
if (rafId) cancelAnimationFrame(rafId)
if (accumulated) {
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
}
}
if (!accumulated) {
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
}
} catch (err: any) {
const isTimeout = err.name === 'AbortError'
setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }])
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
{/* 顶部操作栏 */}
<div className="flex items-center gap-2 pb-2 border-b">
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowConsultModal(true)}><UserCheck className="w-4 h-4 mr-1" /></Button>
{conversations && conversations.length > 0 && (
<span className="text-xs text-gray-400">{conversations.length} </span>
)}
</div>
{/* 历史会话列表 */}
{showHistory && (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => loadConversation(c.id)}>{c.title.replace(/^chat:/, '')}</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); deleteConvMutation.mutate(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)}
<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-[85%] px-4 py-3 rounded-lg text-sm leading-relaxed ${
msg.role === 'user' ? 'bg-primary text-white whitespace-pre-wrap' : 'bg-white border border-gray-200 text-gray-800 shadow-sm'
}`}>
{msg.role === 'assistant' ? (
msg.content ? (
<div className="prose prose-sm max-w-none
prose-headings:text-gray-900 prose-headings:font-semibold
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
prose-p:my-2 prose-p:leading-relaxed
prose-li:my-0.5 prose-li:leading-relaxed
prose-ul:my-2 prose-ol:my-2
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900
prose-hr:border-gray-200 prose-hr:my-4
">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
) : loading && i === messages.length - 1 ? (
<span className="inline-flex items-center gap-1.5 text-gray-500">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</span>
) : null
) : (
msg.content
)}
</div>
</div>
))}
</div>
{/* 快捷问题 */}
{messages.length <= 1 && (
<div className="flex flex-wrap gap-2 pb-3">
{QUICK_QUESTIONS.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
>
{q}
</button>
))}
</div>
)}
{/* 输入框 */}
<div className="flex gap-2 pt-2 border-t">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="输入问题..."
disabled={loading}
/>
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
<Mic className="w-4 h-4" />
</Button>
<Button onClick={() => send()} disabled={loading || !input.trim()}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
{/* 转人工咨询 Modal */}
{showConsultModal && (
<Modal open={true} title="联系专业律师" onClose={() => setShowConsultModal(false)}>
<div className="space-y-3">
<div className="rounded-md bg-blue-50 border border-blue-200 p-3 text-xs text-blue-700">
<p className="font-medium mb-1"></p>
<p>· <strong></strong>线</p>
<p>· <strong></strong></p>
<p>· <strong></strong></p>
<p className="mt-1"> 24 </p>
</div>
<div>
<Label></Label>
<Select value={consultForm.type} onChange={(e) => setConsultForm({ ...consultForm, type: e.target.value })}>
<option value="LEGAL"></option>
<option value="ARBITRATION"></option>
<option value="COURT"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={consultForm.title} onChange={(e) => setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" />
</div>
<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-[80px] resize-y"
value={consultForm.description}
onChange={(e) => setConsultForm({ ...consultForm, description: e.target.value })}
placeholder="请详细描述您遇到的法律问题、涉及的员工情况等"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={consultForm.contactName} onChange={(e) => setConsultForm({ ...consultForm, contactName: e.target.value })} placeholder="您的姓名" />
</div>
<div>
<Label></Label>
<Input value={consultForm.contactPhone} onChange={(e) => setConsultForm({ ...consultForm, contactPhone: e.target.value })} placeholder="手机号码" maxLength={11} />
</div>
</div>
<div>
<Label></Label>
<Input value={consultForm.remark} onChange={(e) => setConsultForm({ ...consultForm, remark: e.target.value })} placeholder="其他需要说明的信息" />
</div>
<div className="flex gap-2 justify-end pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowConsultModal(false)}></Button>
<Button
size="sm"
onClick={() => consultMutation.mutate(consultForm)}
disabled={consultMutation.isPending || !consultForm.title || !consultForm.description || !consultForm.contactName || !consultForm.contactPhone}
>
{consultMutation.isPending ? '提交中...' : '提交咨询'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
@@ -0,0 +1,259 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { Sparkles, Loader2, Download, TrendingUp } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx'
import { saveAs } from 'file-saver'
import { useAuthStore } from '../../store/authStore'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
function parseInlineBold(text: string): TextRun[] {
const runs: TextRun[] = []
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
}
if (match[2]) {
runs.push(new TextRun({ text: match[2], bold: true }))
} else if (match[3]) {
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
}
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
runs.push(new TextRun({ text: text.slice(lastIndex) }))
}
return runs.length ? runs : [new TextRun({ text })]
}
/** 导出 Markdown 文本为 Word 文档 */
async function exportMarkdownToWord(markdown: string, fileName: string) {
const lines = markdown.split('\n')
const children: (Paragraph | Table)[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) { i++; continue }
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
i += 2
const rows: TableRow[] = []
rows.push(new TableRow({
children: headerCells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
shading: { fill: 'F3F4F6' },
})),
}))
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
rows.push(new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text })] })],
})),
}))
i++
}
children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
continue
}
if (line.startsWith('### ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
} else if (line.startsWith('## ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
} else if (line.startsWith('# ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
} else if (line.startsWith('> ')) {
children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } }))
} else if (/^\d+\.\s/.test(line)) {
children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } }))
} else if (line === '---' || line === '***') {
children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } }))
} else {
children.push(new Paragraph({ children: parseInlineBold(line) }))
}
i++
}
const doc = new Document({
numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] },
sections: [{ children }],
})
const blob = await Packer.toBlob(doc)
saveAs(blob, fileName)
}
// 通用 AI 历史记录 hook
// 通用历史记录栏组件
export function HRReportTab() {
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const handleGenerate = async () => {
if (loading) return
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setResult('')
try {
const token = useAuthStore.getState().accessToken
const url = import.meta.env.DEV
? `http://localhost:3000/api/v1/ai/hr-report-stream`
: `/api/v1/ai/hr-report-stream`
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
signal: controller.signal,
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
setResult(accumulated)
}
if (parsed.error) {
throw new Error(parsed.error)
}
} catch (parseErr: any) {
if (parseErr instanceof SyntaxError) continue
throw parseErr
}
}
}
}
setResult(accumulated)
}
} catch (err: any) {
if (err.name !== 'AbortError') {
toast.error(err.message || '生成报告失败')
}
} finally {
setLoading(false)
}
}
const handleExport = async () => {
if (!result) return
try {
await exportMarkdownToWord(result, `人力分析报告_${new Date().toISOString().slice(0, 10)}.docx`)
toast.success('Word 文档已导出')
} catch {
toast.error('导出失败')
}
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium flex items-center gap-1.5">
<TrendingUp className="w-4 h-4 text-primary" />
AI
</h2>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div className="flex items-center gap-2">
{result && !loading && (
<button
onClick={handleExport}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
<Button size="sm" onClick={handleGenerate} disabled={loading}>
{loading ? (
<><Loader2 className="w-4 h-4 mr-1 animate-spin" />...</>
) : (
<><Sparkles className="w-4 h-4 mr-1" /></>
)}
</Button>
</div>
</div>
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<TrendingUp className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm">"生成报告"AI </p>
</div>
)}
{loading && !result && (
<div className="text-center py-12">
<Loader2 className="w-8 h-8 mx-auto mb-3 text-primary animate-spin" />
<p className="text-sm text-gray-500">AI ...</p>
</div>
)}
{result && (
<div className="prose prose-sm max-w-none
prose-headings:text-gray-800 prose-headings:font-semibold
prose-h1:text-lg prose-h1:border-b prose-h1:pb-2 prose-h1:border-gray-200
prose-h2:text-base prose-h2:mt-4
prose-h3:text-sm prose-h3:mt-3
prose-p:text-gray-600 prose-p:leading-relaxed
prose-li:text-gray-600 prose-li:leading-relaxed
prose-strong:text-gray-800
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{result}
</ReactMarkdown>
</div>
)}
</Card>
</div>
)
}
@@ -0,0 +1,157 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { aiApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
// 通用 AI 历史记录 hook
// 通用历史记录栏组件
export function KnowledgeTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
const [adding, setAdding] = useState(false)
const { data: knowledgeList, isLoading } = useQuery<any[]>({
queryKey: ['rag-knowledge'],
queryFn: async () => {
return await aiApi.ragList()
},
})
const addMutation = useMutation({
mutationFn: async (data: typeof newItem) => {
return await aiApi.ragAdd(data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
setShowAdd(false)
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => aiApi.ragRemove(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
})
const seedMutation = useMutation({
mutationFn: () => aiApi.ragSeed(),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
})
const handleAdd = async () => {
if (!newItem.title || !newItem.content) return
setAdding(true)
try {
await addMutation.mutateAsync(newItem)
} finally {
setAdding(false)
}
}
return (
<div className="space-y-3">
<div className="bg-blue-50 border border-blue-200 rounded-md p-3 text-xs text-blue-700 space-y-1">
<div className="font-medium">📖 </div>
<div>· </div>
<div>· AI </div>
<div>· </div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {knowledgeList?.length || 0} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
</Button>
<Button size="sm" onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !knowledgeList || knowledgeList.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<div className="space-y-2">
{knowledgeList.map((item: any) => (
<Card key={item.id}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium">{item.title}</span>
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
</div>
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
<div className="text-xs text-gray-400 mt-1">{item.source}</div>
</div>
<button
onClick={() => deleteMutation.mutate(item.id)}
className="text-gray-400 hover:text-danger flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
</div>
)}
{showAdd && (
<Modal open onClose={() => setShowAdd(false)}>
<div className="space-y-3">
<h3 className="font-medium"></h3>
<div>
<Label></Label>
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
</div>
<div>
<Label></Label>
<textarea
value={newItem.content}
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
placeholder="法律条文或知识内容"
rows={5}
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
</div>
<div>
<Label></Label>
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
<option value="其他"></option>
<option value="法律法规"></option>
<option value="司法解释"></option>
<option value="地方性法规"></option>
<option value="案例分析"></option>
</Select>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}></Button>
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
{adding ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
@@ -0,0 +1,789 @@
import { useState, useRef, useCallback } from 'react'
import { SCENARIO_TYPES } from './shared'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Sparkles, Loader2, Trash2, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Document, Packer, Paragraph, HeadingLevel, TextRun, Table, TableRow, TableCell, WidthType, BorderStyle, AlignmentType } from 'docx'
import { saveAs } from 'file-saver'
import { aiApi, rosterApi, employeeApi } from '../../lib/api-services'
import { useAuthStore } from '../../store/authStore'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
// 通用 AI 历史记录 hook
function useAIHistory(type: 'predict' | 'review' | 'case') {
const queryClient = useQueryClient()
const queryKey = [`ai-history-${type}`]
const { data: history } = useQuery<any[]>({
queryKey,
queryFn: async () => {
return await aiApi.conversations(type)
},
})
const saveMutation = useMutation({
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
const res = await aiApi.createConversation({
title: `${type}:${title}`,
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
}) as any
return res
},
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => aiApi.removeConversation(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const loadHistory = useCallback(async (id: string) => {
return await aiApi.conversation(id)
}, [])
return { history, saveMutation, deleteMutation, loadHistory }
}
// 通用历史记录栏组件
function HistoryBar({ history, onLoad, onDelete }: {
history: any[]
onLoad: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{history.length > 0 ? history.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => onLoad(c.id)}>
{c.title.replace(/^(predict:|review:|case:)/, '')}
</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)
}
export function PredictTab() {
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [mode, setMode] = useState<'general' | 'structured'>('general')
const [scope, setScope] = useState('all')
const [riskType, setRiskType] = useState('all')
const [department, setDepartment] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [showHistory, setShowHistory] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('predict')
// 结构化表单状态
const [scenarioType, setScenarioType] = useState('discipline')
const [structEmployeeName, setStructEmployeeName] = useState('')
const [violationFact, setViolationFact] = useState('')
const [region, setRegion] = useState('')
const [monthlySalary, setMonthlySalary] = useState('')
const [democracyStatus, setDemocracyStatus] = useState('')
const [disciplinaryRecord, setDisciplinaryRecord] = useState('')
const [extraInfo, setExtraInfo] = useState('')
// 系统带出字段标记(区分自动填充 vs HR 手动修改)
const [autoFilledFields, setAutoFilledFields] = useState<{ salary?: boolean; region?: boolean; disciplinary?: boolean; violationFact?: boolean; extraInfo?: boolean }>({})
// 员工特殊状态提示
const [employeeSpecialStatus, setEmployeeSpecialStatus] = useState('')
const { data: employees } = useQuery<any[]>({
queryKey: ['employee-list'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
/** 选择员工后自动带出系统已有数据 */
const handleStructEmployeeChange = async (employeeName: string) => {
setStructEmployeeName(employeeName)
// 清空之前带出的数据
setAutoFilledFields({})
setEmployeeSpecialStatus('')
setExtraInfo('')
if (!employeeName) return
// 从已加载的员工列表中查找(列表数据已含 monthlySalary/isPregnant/city 等字段)
const emp = (employees || []).find((e: any) => e.name === employeeName)
if (!emp) return
// 1. 直接从列表数据带出月薪(已解密)
if (emp.monthlySalary && Number(emp.monthlySalary) > 0) {
setMonthlySalary(String(emp.monthlySalary))
setAutoFilledFields((prev) => ({ ...prev, salary: true }))
}
// 2. 直接从列表数据带出地区
if (emp.city) {
setRegion(emp.city)
setAutoFilledFields((prev) => ({ ...prev, region: true }))
}
// 3. 直接从列表数据带出特殊状态
const statusParts: string[] = []
if (emp.isPregnant) statusParts.push('孕期/哺乳期')
if (emp.isInMedicalPeriod) statusParts.push('医疗期')
if (emp.isWorkInjured) statusParts.push('工伤')
const specialStatus = statusParts.join('、')
setEmployeeSpecialStatus(specialStatus)
// 自动将三期/特殊状态填入补充信息
if (specialStatus) {
setExtraInfo(`员工特殊状态:${specialStatus}`)
setAutoFilledFields((prev) => ({ ...prev, extraInfo: true }))
}
// 4. 获取违纪记录(列表接口未含明细,需调用专用接口)
try {
const res = await rosterApi.disciplinary(emp.id) as any
const records = res || []
if (Array.isArray(records) && records.length > 0) {
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '辞退' }
// 自动填充"违纪/争议事实"文本框
const factText = `【系统已有违纪记录】\n${records.map((r: any) =>
`- ${r.violationDate?.slice(0, 10) || ''} ${typeMap[r.violationType] || r.violationType}${r.description || ''}(处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',已签字' : ',未签字'}`
).join('\n')}\n\n【本次争议事实】请在此描述当前拟处理的具体情况...`
setViolationFact(factText)
setAutoFilledFields((prev) => ({ ...prev, violationFact: true }))
// 自动填充"违纪记录留痕情况"下拉
const hasWrittenAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && r.employeeAck)
const hasWrittenNoAck = records.some((r: any) => r.action === 'WRITTEN_WARNING' && !r.employeeAck)
const hasOralOnly = records.every((r: any) => r.action === 'ORAL_WARNING')
if (hasWrittenAck) {
setDisciplinaryRecord('有书面警告信且员工签收')
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
} else if (hasWrittenNoAck) {
setDisciplinaryRecord('有书面记录但未签收')
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
} else if (hasOralOnly) {
setDisciplinaryRecord('仅有口头警告')
setAutoFilledFields((prev) => ({ ...prev, disciplinary: true }))
}
} else {
// 无违纪记录
}
} catch (err) {
toast.error('获取违纪记录失败')
}
}
/** 通用 SSE 流读取(复用于两种模式) */
const streamSSE = async (response: Response, onDone: (accumulated: string) => void) => {
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
let rafId: number | null = null
let pendingFlush = false
const flush = () => {
pendingFlush = false
rafId = null
setResult(accumulated)
}
const scheduleFlush = () => {
if (!pendingFlush) {
pendingFlush = true
rafId = requestAnimationFrame(flush)
}
}
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
scheduleFlush()
}
if (parsed.error) {
throw new Error(parsed.error)
}
} catch (parseErr: any) {
if (parseErr instanceof SyntaxError) continue
throw parseErr
}
}
}
}
if (rafId) cancelAnimationFrame(rafId)
setResult(accumulated)
if (accumulated && !accumulated.startsWith('**出错了**')) {
onDone(accumulated)
}
}
}
const fetchPrediction = async () => {
if (loading) return
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setResult('')
try {
const token = useAuthStore.getState().accessToken
const params = new URLSearchParams()
if (scope === 'department' && department) params.set('department', department)
if (scope === 'employee' && employeeId) params.set('employeeId', employeeId)
if (riskType !== 'all') params.set('riskType', riskType)
const predictUrl = import.meta.env.DEV
? `http://localhost:3000/api/v1/ai/predict-stream?${params}`
: `/api/v1/ai/predict-stream?${params}`
const response = await fetch(predictUrl, {
method: 'GET',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: controller.signal,
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
await streamSSE(response, (accumulated) => {
const scopeLabel = scope === 'all' ? '全部员工' : scope === 'department' ? department : employees?.find((e: any) => e.id === employeeId)?.name || '指定员工'
const riskLabel = riskType === 'all' ? '全部类型' : riskType
saveMutation.mutate({ title: `${scopeLabel}-${riskLabel}`, input: `范围:${scopeLabel} 类型:${riskLabel}`, result: accumulated })
})
} catch (err: any) {
if (err.name === 'AbortError') return
setResult(`**出错了**${err.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
/** 结构化判赔预测 */
const fetchStructuredPrediction = async () => {
if (loading) return
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setResult('')
try {
const token = useAuthStore.getState().accessToken
const predictUrl = import.meta.env.DEV
? `http://localhost:3000/api/v1/ai/predict-structured`
: `/api/v1/ai/predict-structured`
const response = await fetch(predictUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
scenarioType,
keyFacts: {
employeeName: structEmployeeName || undefined,
violationFact: violationFact || undefined,
region: region || undefined,
monthlySalary: monthlySalary || undefined,
democracyStatus: democracyStatus || undefined,
disciplinaryRecord: disciplinaryRecord || undefined,
extraInfo: extraInfo || undefined,
},
}),
signal: controller.signal,
})
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const scenarioLabel = SCENARIO_TYPES.find((s) => s.value === scenarioType)?.label || scenarioType
await streamSSE(response, (accumulated) => {
saveMutation.mutate({
title: `判赔-${scenarioLabel}${structEmployeeName ? '-' + structEmployeeName : ''}`,
input: `场景:${scenarioLabel} 员工:${structEmployeeName || '未指定'}`,
result: accumulated,
})
})
} catch (err: any) {
if (err.name === 'AbortError') return
setResult(`**出错了**${err.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
const handleLoadHistory = async (id: string) => {
const data = await loadHistory(id)
if (data?.messages) {
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
if (assistantMsg) {
setResult(assistantMsg.content)
setShowHistory(false)
}
}
}
const handlePredict = () => {
if (mode === 'structured') {
fetchStructuredPrediction()
} else {
fetchPrediction()
}
}
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
const parseInlineBold = (text: string): TextRun[] => {
const runs: TextRun[] = []
const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g
let lastIndex = 0
let match
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
}
if (match[2]) {
runs.push(new TextRun({ text: match[2], bold: true }))
} else if (match[3]) {
runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 }))
}
lastIndex = regex.lastIndex
}
if (lastIndex < text.length) {
runs.push(new TextRun({ text: text.slice(lastIndex) }))
}
return runs.length ? runs : [new TextRun({ text })]
}
/** 导出 AI 分析结果为 Word 文档 */
const handleExportWord = async () => {
if (!result) return
try {
const lines = result.split('\n')
const children: (Paragraph | Table)[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
// 跳过空行
if (!line.trim()) { i++; continue }
// 表格(markdown GFM 表格语法)
if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) {
const headerCells = line.split('|').map(c => c.trim()).filter(Boolean)
i += 2 // 跳过分隔行
const rows: TableRow[] = []
// 表头
rows.push(new TableRow({
children: headerCells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })],
shading: { fill: 'F3F4F6' },
})),
}))
// 数据行
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) {
const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean)
rows.push(new TableRow({
children: cells.map(text => new TableCell({
children: [new Paragraph({ children: [new TextRun({ text })] })],
})),
}))
i++
}
children.push(new Table({
rows,
width: { size: 100, type: WidthType.PERCENTAGE },
}))
continue
}
// 标题
if (line.startsWith('### ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] }))
} else if (line.startsWith('## ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] }))
} else if (line.startsWith('# ')) {
children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] }))
} else if (line.startsWith('> ')) {
// 引用块
children.push(new Paragraph({
children: [new TextRun({ text: line.slice(2), italics: true })],
indent: { left: 720 },
}))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
// 无序列表
children.push(new Paragraph({
children: parseInlineBold(line.slice(2)),
bullet: { level: 0 },
}))
} else if (/^\d+\.\s/.test(line)) {
// 有序列表
children.push(new Paragraph({
children: parseInlineBold(line.replace(/^\d+\.\s/, '')),
numbering: { reference: 'default-numbering', level: 0 },
}))
} else if (line === '---' || line === '***') {
// 分隔线
children.push(new Paragraph({
children: [],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } },
}))
} else {
// 普通段落(支持 **bold** 和 `code`
children.push(new Paragraph({
children: parseInlineBold(line),
}))
}
i++
}
const doc = new Document({
numbering: {
config: [{
reference: 'default-numbering',
levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }],
}],
},
sections: [{ children }],
})
const blob = await Packer.toBlob(doc)
const fileName = mode === 'structured'
? `判赔预测报告_${structEmployeeName || '未指定员工'}_${new Date().toISOString().slice(0, 10)}.docx`
: `风险预测报告_${new Date().toISOString().slice(0, 10)}.docx`
saveAs(blob, fileName)
toast.success('Word 文档已导出')
} catch (err) {
toast.error('导出失败,请重试')
}
}
return (
<Card>
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-primary" />
<h2 className="text-sm font-medium">AI </h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
{showHistory && (
<div className="mt-2">
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
</div>
)}
{/* 模式切换 */}
<div className="flex gap-1 mb-4 mt-3 border-b pb-2">
<button
onClick={() => { setMode('general'); setResult('') }}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
mode === 'general' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => { setMode('structured'); setResult('') }}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
mode === 'structured' ? 'bg-primary text-white' : 'text-gray-500 hover:bg-gray-50'
}`}
>
</button>
</div>
{/* === 通用模式筛选条件 === */}
{mode === 'general' && (
<div className="flex items-center gap-2 mb-4 flex-wrap">
<div className="min-w-[120px]">
<Button size="sm" onClick={handlePredict} disabled={loading}>
{loading ? '分析中...' : result ? '重新预测' : '开始预测'}
</Button>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
<option value="all"></option>
<option value="department"></option>
<option value="employee"></option>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={riskType} onChange={(e) => setRiskType(e.target.value)}>
<option value="all"></option>
<option value="contract"></option>
<option value="salary"></option>
<option value="termination"></option>
</Select>
</div>
{scope === 'department' && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={department} onChange={(e) => setDepartment(e.target.value)}>
<option value=""></option>
{departments.map((d: string) => <option key={d} value={d}>{d}</option>)}
</Select>
</div>
)}
{scope === 'employee' && (
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap"></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}</option>)}
</Select>
</div>
)}
</div>
)}
{/* === 通用模式 AI 结果 === */}
{mode === 'general' && loading && !result && (
<div className="flex items-center gap-2 text-gray-400 py-8">
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
)}
{mode === 'general' && result && (
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
prose-headings:text-gray-900 prose-headings:font-semibold
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
prose-p:my-2 prose-p:leading-relaxed
prose-li:my-0.5 prose-li:leading-relaxed
prose-ul:my-2 prose-ol:my-2
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-100 prose-th:px-2 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-300 prose-th:whitespace-nowrap
prose-td:px-2 prose-td:py-1.5 prose-td:border prose-td:border-gray-300 prose-td:align-top
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900
prose-hr:border-gray-200 prose-hr:my-4">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
</div>
)}
{mode === 'general' && !result && !loading && (
<div className="text-center py-8 text-gray-400">
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-xs">AI风险分析</p>
</div>
)}
{/* === 结构化判赔预测:左右两栏布局 === */}
{mode === 'structured' && (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4 mb-4">
{/* 左栏:表单(占 2/5 */}
<div className="space-y-3 lg:col-span-2">
{/* 卡片1:员工信息 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<User className="w-3.5 h-3.5 text-primary" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label> <span className="text-danger">*</span></Label>
<Select value={scenarioType} onChange={(e) => setScenarioType(e.target.value)}>
{SCENARIO_TYPES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Select value={structEmployeeName} onChange={(e) => handleStructEmployeeChange(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.name}>{e.name}{e.department}</option>)}
</Select>
{employeeSpecialStatus && (
<div className="mt-1 flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 px-2 py-1 rounded">
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
<strong>{employeeSpecialStatus}</strong>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.region && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Input
placeholder="如:北京"
value={region}
onChange={(e) => { setRegion(e.target.value); setAutoFilledFields((prev) => ({ ...prev, region: false })) }}
/>
</div>
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.salary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Input
type="number"
placeholder="如:8000"
value={monthlySalary}
onChange={(e) => { setMonthlySalary(e.target.value); setAutoFilledFields((prev) => ({ ...prev, salary: false })) }}
/>
</div>
</div>
</div>
{/* 卡片2:争议事实 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<FileText className="w-3.5 h-3.5 text-primary" />
</div>
<Label className="flex items-center gap-1">
/
{autoFilledFields.violationFact && <span title="系统带出,请补充本次争议事实"><Database className="w-3 h-3 text-primary" /></span>}
</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-xs min-h-[80px] resize-y"
placeholder="描述具体的违纪事实或争议情况,例如:员工连续旷工3天,公司拟以严重违纪为由解除劳动合同..."
value={violationFact}
onChange={(e) => { setViolationFact(e.target.value); setAutoFilledFields((prev) => ({ ...prev, violationFact: false })) }}
/>
<div className="mt-2">
<Label>
{autoFilledFields.extraInfo && <span title="系统带出"><Database className="w-3 h-3 text-primary inline" /></span>}
</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-xs min-h-[50px] resize-y"
placeholder="其他需要说明的情况,如是否有工会等..."
value={extraInfo}
onChange={(e) => { setExtraInfo(e.target.value); setAutoFilledFields((prev) => ({ ...prev, extraInfo: false })) }}
/>
</div>
</div>
{/* 卡片3:制度合规 */}
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-gray-700">
<Shield className="w-3.5 h-3.5 text-primary" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={democracyStatus} onChange={(e) => setDemocracyStatus(e.target.value)}>
<option value=""></option>
<option value="已履行民主程序并公示"></option>
<option value="已公示但未履行民主程序"></option>
<option value="未公示未履行民主程序"></option>
<option value="不确定"></option>
</Select>
</div>
<div>
<Label className="flex items-center gap-1">
{autoFilledFields.disciplinary && <span title="系统带出"><Database className="w-3 h-3 text-primary" /></span>}
</Label>
<Select value={disciplinaryRecord} onChange={(e) => { setDisciplinaryRecord(e.target.value); setAutoFilledFields((prev) => ({ ...prev, disciplinary: false })) }}>
<option value=""></option>
<option value="有书面警告信且员工签收"></option>
<option value="有书面记录但未签收"></option>
<option value="仅有口头警告"></option>
<option value="无任何记录"></option>
</Select>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button size="sm" onClick={handlePredict} disabled={loading}>
{loading ? '分析中...' : result ? '重新预测' : '开始判赔预测'}
</Button>
<span className="text-xs text-gray-400">AI </span>
</div>
</div>
{/* 右栏:AI 结果(占 3/5 */}
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50 flex flex-col lg:col-span-3" style={{ height: 'calc(100vh - 320px)', maxHeight: 'calc(100vh - 320px)' }}>
<div className="flex items-center justify-between mb-2.5 flex-shrink-0">
<div className="flex items-center gap-1.5 text-xs font-semibold text-gray-700">
<Sparkles className="w-3.5 h-3.5 text-primary" />
AI
</div>
{result && !loading && (
<button
onClick={handleExportWord}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors"
>
<Download className="w-3.5 h-3.5" />
Word
</button>
)}
</div>
<div className="flex-1 overflow-y-auto">
{loading && !result && (
<div className="flex items-center gap-2 text-gray-400 py-8">
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
)}
{result && (
<div className="prose prose-sm max-w-none overflow-x-auto
prose-headings:text-gray-900 prose-headings:font-semibold
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
prose-p:my-2 prose-p:leading-relaxed
prose-li:my-0.5 prose-li:leading-relaxed
prose-ul:my-2 prose-ol:my-2
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
prose-table:text-xs prose-table:border-collapse
prose-th:bg-gray-100 prose-th:px-2 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-300 prose-th:whitespace-nowrap
prose-td:px-2 prose-td:py-1.5 prose-td:border prose-td:border-gray-300 prose-td:align-top
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900
prose-hr:border-gray-200 prose-hr:my-4">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result}</ReactMarkdown>
{loading && <Loader2 className="w-4 h-4 animate-spin inline-block text-gray-400 ml-1" />}
</div>
)}
{!result && !loading && (
<div className="text-center py-12 text-gray-400">
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-xs"></p>
</div>
)}
</div>
</div>
</div>
)}
</Card>
)
}
@@ -0,0 +1,292 @@
import { useState, useRef, useCallback } from 'react'
import { REVIEW_DOC_TYPES } from './shared'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { FileSearch, Loader2, Trash2, Save, History, FileText } from 'lucide-react'
import { aiApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */
// 通用 AI 历史记录 hook
function useAIHistory(type: 'predict' | 'review' | 'case') {
const queryClient = useQueryClient()
const queryKey = [`ai-history-${type}`]
const { data: history } = useQuery<any[]>({
queryKey,
queryFn: async () => {
return await aiApi.conversations(type)
},
})
const saveMutation = useMutation({
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
const res = await aiApi.createConversation({
title: `${type}:${title}`,
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
}) as any
return res
},
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => aiApi.removeConversation(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
})
const loadHistory = useCallback(async (id: string) => {
return await aiApi.conversation(id)
}, [])
return { history, saveMutation, deleteMutation, loadHistory }
}
// 通用历史记录栏组件
function HistoryBar({ history, onLoad, onDelete }: {
history: any[]
onLoad: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{history.length > 0 ? history.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => onLoad(c.id)}>
{c.title.replace(/^(predict:|review:|case:)/, '')}
</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)
}
export function ReviewTab() {
const [contractText, setContractText] = useState('')
const [result, setResult] = useState<any>(null)
const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [docType, setDocType] = useState('labor_contract')
const [fileName, setFileName] = useState('')
const [showSaveModal, setShowSaveModal] = useState(false)
const [saveEmployeeId, setSaveEmployeeId] = useState('')
const [showHistory, setShowHistory] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const ext = file.name.toLowerCase().split('.').pop()
if (ext !== 'docx' && ext !== 'doc') {
toast.error('仅支持 .docx 格式文件')
return
}
if (file.size > 100 * 1024 * 1024) {
toast.error('文件大小不能超过 100MB')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await aiApi.reviewUpload(formData) as any
if (res?.text) {
setContractText(res.data.text)
setFileName(file.name)
toast.success(`已提取文件内容(${res.data.text.length} 字)`)
}
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '文件上传失败')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const { data: employees } = useQuery<any[]>({
queryKey: ['employee-list'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const handleReview = async () => {
if (!contractText.trim()) return
setLoading(true)
setResult(null)
try {
const res = await aiApi.review(contractText) as any
setResult(res)
// 自动保存到历史
if (res && !res.error) {
const title = contractText.slice(0, 30).replace(/\n/g, ' ')
saveMutation.mutate({ title, input: contractText, result: res.data.text || JSON.stringify(res.data) })
}
} catch (err: any) {
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
} finally {
setLoading(false)
}
}
const handleLoadHistory = async (id: string) => {
const data = await loadHistory(id)
if (data?.messages) {
const userMsg = data.messages.find((m: any) => m.role === 'user')
const assistantMsg = data.messages.find((m: any) => m.role === 'assistant')
if (userMsg) setContractText(userMsg.content)
if (assistantMsg) {
try { setResult(JSON.parse(assistantMsg.content)) } catch { setResult({ text: assistantMsg.content }) }
}
setShowHistory(false)
}
}
const handleSave = async () => {
if (!saveEmployeeId || !result) return
try {
await aiApi.reviewSave({ employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
setShowSaveModal(false)
setSaveEmployeeId('')
toast.success('已保存到员工档案')
} catch (err: any) {
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
}
}
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
}
return (
<div className="space-y-4">
<Card>
<div className="flex items-center gap-2 mb-4">
<FileSearch className="w-5 h-5 text-primary" />
<h2 className="text-sm font-medium"></h2>
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" /></Button>
</div>
{showHistory && (
<div className="mt-2 mb-3">
<HistoryBar history={history || []} onLoad={handleLoadHistory} onDelete={(id) => deleteMutation.mutate(id)} />
</div>
)}
<div className="mt-3 space-y-3">
{/* 文件上传区 */}
<div>
<Label></Label>
<div className="flex items-center gap-2">
<Select value={docType} onChange={(e) => setDocType(e.target.value)} className="w-40">
{REVIEW_DOC_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
<input ref={fileInputRef} type="file" accept=".docx,.txt,.pdf" onChange={handleFileUpload} className="hidden" />
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? (<><Loader2 className="w-4 h-4 animate-spin mr-1" />...</>) : (<><FileText className="w-4 h-4 mr-1" /></>)}
</Button>
{fileName && <span className="text-xs text-gray-500 truncate max-w-[200px]">{fileName}</span>}
</div>
</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-xs min-h-[200px] resize-y"
placeholder="粘贴劳动合同文本..."
value={contractText}
onChange={(e) => setContractText(e.target.value)}
/>
<div className="mt-3">
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />...</> : '开始审查'}
</Button>
</div>
</div>
</Card>
{result && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" /></Button>
</div>
{result.error ? (
<div className="text-xs text-danger">{result.error}</div>
) : result.structured ? (
<div className="space-y-3">
{/* 合规评分 */}
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500"></span>
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
{result.structured.score}/100
</span>
</div>
{/* 风险项列表 */}
{result.structured.riskItems.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium">{result.structured.riskItems.length}</h4>
{result.structured.riskItems.map((item: any, i: number) => {
const cfg = levelConfig[item.level] || levelConfig.YELLOW
return (
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
<span className="text-xs font-medium">{item.title}</span>
</div>
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
<div className="text-xs text-gray-500">{item.suggestion}</div>
</div>
)
})}
</div>
)}
{/* 总体建议 */}
{result.structured.summary && (
<div className="border-t pt-2">
<h4 className="text-xs font-medium mb-1"></h4>
<p className="text-xs text-gray-600">{result.structured.summary}</p>
</div>
)}
{/* 原始文本可展开 */}
<details className="border-t pt-2">
<summary className="text-xs text-gray-400 cursor-pointer"></summary>
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
</details>
</div>
) : (
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(result)}</div>
)}
</Card>
)}
{showSaveModal && (
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
<div className="space-y-3">
<h3 className="font-medium"></h3>
<Label></Label>
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}></Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
/** 12 类争议场景 */
export const SCENARIO_TYPES = [
{ value: 'discipline', label: '违纪解除' },
{ value: 'incompetence', label: '不胜任解除' },
{ value: 'probation', label: '试用期解除' },
{ value: 'layoff', label: '经济性裁员' },
{ value: 'expiry', label: '合同到期不续签' },
{ value: 'negotiated', label: '协商解除' },
{ value: 'transfer', label: '调岗调薪争议' },
{ value: 'overtime', label: '加班费争议' },
{ value: 'injury', label: '工伤待遇争议' },
{ value: 'noncompete', label: '竞业限制争议' },
{ value: 'confidentiality', label: '保密协议争议' },
{ value: 'social_insurance', label: '社保公积金争议' },
]
export const REVIEW_DOC_TYPES = [
{ value: 'labor_contract', label: '劳动合同' },
{ value: 'rescission', label: '协商解除协议' },
{ value: 'labor_service', label: '劳务协议' },
{ value: 'internship', label: '实习协议' },
{ value: 'nda', label: '保密协议' },
{ value: 'other', label: '其他' },
]
+123 -28
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Eye, EyeOff } from 'lucide-react'
import { Eye, EyeOff, Shield, Zap, Users, FileText, ArrowRight } from 'lucide-react'
import Logo from '../../components/ui/Logo'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -67,27 +67,99 @@ export default function Login() {
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Logo className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
</div>
const features = [
{ icon: Users, title: '智能花名册', desc: '员工档案全生命周期管理' },
{ icon: FileText, title: '合同自动化', desc: '电子签约 + 到期预警' },
{ icon: Shield, title: '合规风控', desc: 'AI 驱动的用工风险检测' },
{ icon: Zap, title: '薪税一键算', desc: '工资社保个税自动计算' },
]
<div className="card">
<h1 className="text-lg font-semibold mb-4"></h1>
return (
<div className="min-h-screen flex bg-surface-page">
{/* 左侧品牌展示区 */}
<div className="hidden lg:flex lg:w-[480px] xl:w-[540px] relative overflow-hidden bg-gradient-to-br from-brand-700 via-brand-600 to-brand-800">
{/* 装饰性几何图形 */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-72 h-72 rounded-full border-[3px] border-white" />
<div className="absolute bottom-32 right-10 w-48 h-48 rounded-full border-[2px] border-white" />
<div className="absolute top-1/2 left-1/3 w-96 h-96 rounded-full border-[1px] border-white" />
</div>
<div className="absolute top-0 right-0 w-40 h-40 bg-white/5 rounded-bl-[80px]" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white/5 rounded-tr-[64px]" />
<div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full">
{/* Logo + 品牌名 */}
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center">
<Logo className="w-7 h-7 text-white" />
</div>
<div>
<div className="text-xl font-bold tracking-tight"></div>
<div className="text-xs text-white/60 mt-0.5">TurboHR · </div>
</div>
</div>
{/* 标语 */}
<div>
<h2 className="text-3xl xl:text-4xl font-bold leading-tight tracking-tight">
<br />
</h2>
<p className="mt-4 text-sm text-white/70 leading-relaxed max-w-sm">
AI
</p>
</div>
{/* 功能亮点 */}
<div className="space-y-3">
{features.map((f, i) => (
<div key={i} className="flex items-center gap-3 group">
<div className="w-9 h-9 rounded-lg bg-white/10 backdrop-blur flex items-center justify-center flex-shrink-0 group-hover:bg-white/20 transition-colors">
<f.icon className="w-4.5 h-4.5 text-white" strokeWidth={1.8} />
</div>
<div>
<div className="text-sm font-semibold text-white/95">{f.title}</div>
<div className="text-xs text-white/55 mt-0.5">{f.desc}</div>
</div>
</div>
))}
</div>
{/* 底部版权 */}
<div className="text-xs text-white/40">
© 2026 TurboHR ·
</div>
</div>
</div>
{/* 右侧登录表单区 */}
<div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
{/* 移动端 Logo */}
<div className="flex lg:hidden items-center justify-center gap-2 mb-8">
<Logo className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
</div>
{/* 欢迎语 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1.5"></p>
</div>
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
<div className="mb-5 px-4 py-3 rounded-lg bg-red-50 border border-red-100 text-red-700 text-sm flex items-start gap-2">
<span className="flex-shrink-0 mt-0.5"></span>
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div>
<Label></Label>
<Input
type="tel"
placeholder="请输入手机号"
className="h-11"
{...register('phone')}
maxLength={11}
/>
@@ -100,38 +172,61 @@ export default function Login() {
<Input
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
className="h-11 pr-10"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPassword ? <EyeOff className="w-4.5 h-4.5" /> : <Eye className="w-4.5 h-4.5" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary focus:ring-offset-0"
/>
<span className="text-sm text-gray-500"></span>
</label>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary focus:ring-offset-0"
/>
<span className="text-sm text-gray-500"></span>
</label>
<Link to="/forgot-password" className="text-sm text-primary hover:text-primary-dark transition-colors"></Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '登录中...' : '登录'}
<Button type="submit" size="lg" className="w-full h-11" disabled={loading}>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</span>
) : (
<span className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
</span>
)}
</Button>
</form>
<div className="mt-4 flex items-center justify-between text-sm">
<Link to="/forgot-password" className="text-primary hover:underline"></Link>
<Link to="/register" className="text-primary hover:underline"></Link>
{/* 分割线 */}
<div className="mt-8 mb-6 flex items-center gap-4">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-xs text-gray-400"></span>
<div className="flex-1 h-px bg-gray-200" />
</div>
<Link
to="/register"
className="block w-full h-11 leading-[44px] text-center rounded-md border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-400 transition-colors"
>
</Link>
</div>
</div>
</div>
+9 -3
View File
@@ -10,8 +10,9 @@ import {
TrendingDown, Calendar, ChevronRight, Filter,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { InlineAlert } from '../../components/ui/InlineAlert'
import PageGuide from '../../components/ui/PageGuide'
import QueryError from '../../components/ui/QueryError'
import { dashboardApi } from '../../lib/api-services'
/** 风险等级配置 */
@@ -33,10 +34,10 @@ const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
const [filterType] = useState<string>('ALL')
/** 获取风险列表 */
const { data: risks = [], isLoading } = useQuery<any[]>({
const { data: risks = [], isLoading, isError, error, refetch } = useQuery<any[]>({
queryKey: ['risk-center'],
queryFn: async () => {
return await dashboardApi.risks()
@@ -71,6 +72,9 @@ export default function RiskCenter() {
return (
<div className="space-y-4">
<PageGuide>
//
</PageGuide>
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -163,6 +167,8 @@ export default function RiskCenter() {
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : filteredRisks.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
@@ -3,7 +3,7 @@
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from 'recharts'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'
import { Award, TrendingUp } from 'lucide-react'
import { dashboardApi } from '../../lib/api-services'
File diff suppressed because it is too large Load Diff
+454
View File
@@ -0,0 +1,454 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Info, Check, Upload, Settings as SettingsIcon, FileText, X } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi, employeeApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function OvertimeCalculator() {
const queryClient = useQueryClient()
const [step, setStep] = useState<1 | 2 | 3>(1)
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewData, setPreviewData] = useState<any[]>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editForm, setEditForm] = useState({ weekdayHours: 0, weekendHours: 0, holidayHours: 0 })
// 加班费规则配置
const { data: config, isLoading: configLoading } = useQuery<any>({
queryKey: ['overtime-config'],
queryFn: async () => {
return await payrollApi.overtimeConfig()
},
})
const saveConfigMutation = useMutation({
mutationFn: (data: any) => payrollApi.saveOvertimeConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-config'] })
},
})
// 员工列表
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
queryKey: ['employees-for-overtime'],
queryFn: async () => {
return await employeeApi.paged({ pageSize: 100 })
},
})
// 加班记录
const { data: overtimeRecords, refetch } = useQuery<any[]>({
queryKey: ['overtime-records', month],
queryFn: async () => {
return await payrollApi.overtimeRecords({ month })
},
enabled: step === 3,
})
const batchImportMutation = useMutation({
mutationFn: (data: any[]) => payrollApi.batchImportOvertime(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setPreviewData([])
setStep(3)
},
})
// 更新单条加班记录
const updateOvertimeMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
payrollApi.updateOvertime(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
setEditingId(null)
},
})
// 开始编辑
const startEdit = (record: any) => {
setEditingId(record.id)
setEditForm({
weekdayHours: record.weekdayHours || 0,
weekendHours: record.weekendHours || 0,
holidayHours: record.holidayHours || 0,
})
}
// 保存编辑
const saveEdit = () => {
if (editingId) {
updateOvertimeMutation.mutate({ id: editingId, data: editForm })
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const empList = employees?.items || []
const fileName = file.name.toLowerCase()
const parseRows = (rows: any[]): void => {
const items: any[] = []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
// 兼容中文列名和英文列名
const empName = String(row['姓名'] ?? row['name'] ?? row['姓名*'] ?? '').trim()
if (!empName) continue
const emp = empList.find(e => e.name === empName)
if (!emp) continue
items.push({
employeeId: emp.id,
employeeName: emp.name,
department: emp.department,
month: String(row['月份'] ?? row['month'] ?? '').trim() || month,
weekdayHours: Number(row['工作日加班时长'] ?? row['weekdayHours'] ?? row['工作日'] ?? 0) || 0,
weekendHours: Number(row['休息日加班时长'] ?? row['weekendHours'] ?? row['休息日'] ?? 0) || 0,
holidayHours: Number(row['法定节假日加班时长'] ?? row['holidayHours'] ?? row['法定节假日'] ?? 0) || 0,
})
}
if (items.length > 0) {
setPreviewData(items)
} else {
toast.error('未匹配到员工,请确保文件包含"姓名"列')
}
}
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
// Excel 格式解析
const reader = new FileReader()
reader.onload = async (event) => {
try {
const data = new Uint8Array(event.target?.result as ArrayBuffer)
const XLSX = await import('xlsx')
const wb = XLSX.read(data, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json(ws)
parseRows(rows)
} catch {
toast.error('Excel 文件解析失败')
}
}
reader.readAsArrayBuffer(file)
} else {
// CSV 格式解析(保持兼容)
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
const lines = text.split('\n').filter(l => l.trim())
if (lines.length < 2) { toast.error('CSV 文件内容为空'); return }
// 解析表头
const headers = lines[0].split(',').map(c => c.trim())
const rows: any[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim())
const row: any = {}
headers.forEach((h, idx) => { row[h] = cols[idx] ?? '' })
rows.push(row)
}
parseRows(rows)
}
reader.readAsText(file)
}
}
const confirmImport = () => {
const payload = previewData.map(d => ({
employeeId: d.employeeId,
month: d.month,
weekdayHours: d.weekdayHours,
weekendHours: d.weekendHours,
holidayHours: d.holidayHours,
}))
batchImportMutation.mutate(payload)
}
const cfg = config || { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
return (
<div className="space-y-4">
<PageGuide>
//
</PageGuide>
{/* 步骤指示器 */}
<div className="flex items-center gap-2">
{[
{ n: 1, label: '设定计算规则' },
{ n: 2, label: '导入考勤数据' },
{ n: 3, label: '查看加班记录' },
].map((s) => (
<div key={s.n} className="flex items-center gap-2">
<button
onClick={() => setStep(s.n as 1 | 2 | 3)}
className={`px-3 py-1.5 rounded text-xs flex items-center gap-1.5 transition-colors ${
step === s.n ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
<span className={`w-4 h-4 rounded-full flex items-center justify-center text-xs ${
step === s.n ? 'bg-white/20' : step > s.n ? 'bg-safe text-white' : 'bg-gray-300 text-white'
}`}>{step > s.n ? '✓' : s.n}</span>
{s.label}
</button>
{s.n < 3 && <div className="w-4 h-px bg-gray-300" />}
</div>
))}
</div>
{/* Step 1: 设定计算规则 */}
{step === 1 && (
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><SettingsIcon className="w-4 h-4" /></h2>
{configLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : (
<div className="space-y-4">
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label></Label>
<Input type="number" step="0.1" defaultValue={cfg.weekdayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekdayRate: Number(e.target.value) })} />
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div>
<Label></Label>
<Input type="number" step="0.1" defaultValue={cfg.weekendRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, weekendRate: Number(e.target.value) })} />
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div>
<Label></Label>
<Input type="number" step="0.1" defaultValue={cfg.holidayRate} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, holidayRate: Number(e.target.value) })} />
<p className="text-xs text-gray-500 mt-1"></p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label></Label>
<Input type="number" step="0.01" defaultValue={cfg.monthlyDays} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, monthlyDays: Number(e.target.value) })} />
<p className="text-xs text-gray-500 mt-1">/21.75</p>
</div>
<div>
<Label></Label>
<Input type="number" step="0.5" defaultValue={cfg.dailyHours} onBlur={(e) => saveConfigMutation.mutate({ ...cfg, dailyHours: Number(e.target.value) })} />
<p className="text-xs text-gray-500 mt-1">8</p>
</div>
</div>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p> = ÷ ÷ </p>
<p> = × × </p>
<p className="mt-1 text-gray-500"></p>
</div>
</div>
{saveConfigMutation.isSuccess && (
<div className="text-xs text-safe flex items-center gap-1"><Check className="w-3.5 h-3.5" /></div>
)}
<div className="flex justify-end">
<Button onClick={() => setStep(2)}> </Button>
</div>
</div>
)}
</Card>
)}
{/* Step 2: 导入考勤数据 */}
{step === 2 && (
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Upload className="w-4 h-4" /></h2>
<div className="space-y-4">
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
</div>
<div className="border-t pt-3">
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileUpload} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={batchImportMutation.isPending}>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '选择CSV文件'}
</Button>
<div className="text-xs text-gray-500 mt-2">
CSV格式,(h),(h),(h),()
</div>
</div>
{previewData.length > 0 && (
<div className="border-t pt-3 space-y-3">
<div className="text-xs font-medium text-gray-700">{previewData.length}</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{previewData.map((d, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-2">{d.employeeName}</td>
<td className="py-2 text-gray-500">{d.department}</td>
<td className="py-2 text-right">{d.weekdayHours}</td>
<td className="py-2 text-right">{d.weekendHours}</td>
<td className="py-2 text-right">{d.holidayHours}</td>
<td className="py-2 text-gray-500">{d.month}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button onClick={confirmImport} disabled={batchImportMutation.isPending}>
{batchImportMutation.isPending ? '保存中...' : '确认导入'}
</Button>
<Button variant="secondary" onClick={() => setPreviewData([])}></Button>
</div>
</div>
)}
<div className="flex justify-between border-t pt-3">
<Button variant="secondary" onClick={() => setStep(1)}> </Button>
<Button onClick={() => setStep(3)}> </Button>
</div>
</div>
</Card>
)}
{/* Step 3: 查看加班记录 */}
{step === 3 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" />{month}</h2>
<div className="flex items-center gap-2">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-32" />
<Button variant="secondary" size="sm" onClick={() => refetch()}></Button>
</div>
</div>
{!overtimeRecords || overtimeRecords.length === 0 ? (
<div className="text-center py-8 text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right">(h)</th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{overtimeRecords.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{r.employee?.name}</td>
<td className="py-2 text-gray-500">{r.employee?.department}</td>
{editingId === r.id ? (
<>
<td className="py-1 text-right">
<input
type="number"
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
value={editForm.weekdayHours}
onChange={(e) => setEditForm({ ...editForm, weekdayHours: Number(e.target.value) })}
min="0"
step="0.5"
/>
</td>
<td className="py-1 text-right">
<input
type="number"
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
value={editForm.weekendHours}
onChange={(e) => setEditForm({ ...editForm, weekendHours: Number(e.target.value) })}
min="0"
step="0.5"
/>
</td>
<td className="py-1 text-right">
<input
type="number"
className="w-16 text-right border rounded px-1 py-0.5 text-xs"
value={editForm.holidayHours}
onChange={(e) => setEditForm({ ...editForm, holidayHours: Number(e.target.value) })}
min="0"
step="0.5"
/>
</td>
</>
) : (
<>
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekdayHours || '-'}</td>
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.weekendHours || '-'}</td>
<td className="py-2 text-right cursor-pointer hover:text-blue-600" onClick={() => startEdit(r)}>{r.holidayHours || '-'}</td>
</>
)}
<td className="py-2 text-right font-medium text-gray-700">
{r.totalPay > 0 ? `¥${fmt(r.totalPay)}` : <span className="text-gray-500"></span>}
</td>
<td className="py-2 text-center">
{r.batchId ? (
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs"></span>
) : (
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs"></span>
)}
</td>
<td className="py-2 text-center">
{editingId === r.id ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={saveEdit}
disabled={updateOvertimeMutation.isPending}
className="text-safe hover:text-green-700 disabled:opacity-50"
title="保存"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setEditingId(null)}
className="text-gray-500 hover:text-gray-600"
title="取消"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
!r.batchId && (
<button
onClick={() => startEdit(r)}
className="text-gray-500 hover:text-blue-600"
title="编辑"
>
<FileText className="w-4 h-4" />
</button>
)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-between border-t pt-3 mt-3">
<Button variant="secondary" onClick={() => setStep(2)}> </Button>
<div className="text-xs text-gray-500 flex items-center gap-1">
<Info className="w-3.5 h-3.5" />
</div>
</div>
</Card>
)}
</div>
)
}
+237
View File
@@ -0,0 +1,237 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Calculator, Check, Layers, Settings as X } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
import Pagination from '../../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export function PayslipManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [showTaxPreview, setShowTaxPreview] = useState(false)
const [previewData, setPreviewData] = useState({
baseSalary: 0,
overtimePay: 0,
allowance: 0,
deduction: 0,
bonus: 0,
specialDeduction: 0,
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryFn: async () => {
return await payrollApi.payslips({ month })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => payrollApi.removePayslip(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
})
const generateFromBatchMutation = useMutation({
mutationFn: (data: any) => payrollApi.generatePayslips(data?.month || data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
const n = res?.data?.generated || 0
toast.success(`已从归档批次汇总生成 ${n} 条工资条并发布。`)
},
})
const taxPreviewMutation = useMutation({
mutationFn: (data: any) => payrollApi.taxPreview(data),
onSuccess: (res: any) => {
setTaxResult(res.data)
setShowTaxPreview(true)
},
})
const [taxResult, setTaxResult] = useState<any>(null)
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
return (
<div className="space-y-3">
<PageGuide>
PDF
</PageGuide>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-xs">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button
onClick={() => setShowTaxPreview(true)}
>
<Calculator className="w-4 h-4 mr-1" />
</Button>
<Button
onClick={async () => {
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
generateFromBatchMutation.mutate({ month })
}
}}
disabled={generateFromBatchMutation.isPending}
>
<Layers className="w-4 h-4 mr-1" />
{generateFromBatchMutation.isPending ? '生成中...' : '从批次汇总生成'}
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : !payslips || payslips.length === 0 ? (
<Card><div className="text-center py-8 text-gray-500"></div></Card>
) : (
<Card>
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-right"></th>
<th className="py-2 px-2 text-center"></th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{payslips.slice((page - 1) * pageSize, page * pageSize).map((p: any) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-2 text-sm font-medium">{p.employee?.name}</td>
<td className="py-2 px-2 text-gray-500">{p.employee?.department}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.baseSalary)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.overtimePay)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.allowance)}</td>
<td className="py-2 px-2 text-right">¥{fmt(p.bonus)}</td>
<td className="py-2 px-2 text-right text-danger">{p.deduction > 0 ? '-¥' + fmt(p.deduction) : '¥0'}</td>
<td className="py-2 px-2 text-right font-medium text-primary">¥{fmt(p.totalPay)}</td>
<td className="py-2 px-2 text-right text-danger">¥{fmt(p.tax)}</td>
<td className="py-2 px-2 text-right font-bold text-safe">¥{fmt(p.netPay)}</td>
<td className="py-2 px-2 text-center">
{p.confirmedAt ? (
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
)}
</td>
<td className="py-2 px-2">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="text-xs text-gray-500 hover:text-danger"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* 税率试算 Modal */}
{showTaxPreview && (
<Modal open onClose={() => { setShowTaxPreview(false); setTaxResult(null) }}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<button onClick={() => { setShowTaxPreview(false); setTaxResult(null) }} className="text-gray-500 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={previewData.baseSalary || ''} onChange={(e) => setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.overtimePay || ''} onChange={(e) => setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.allowance || ''} onChange={(e) => setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.bonus || ''} onChange={(e) => setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.deduction || ''} onChange={(e) => setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
</div>
<div>
<Label></Label>
<Input type="number" value={previewData.specialDeduction || ''} onChange={(e) => setPreviewData({ ...previewData, specialDeduction: Number(e.target.value) })} placeholder="请输入" />
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => taxPreviewMutation.mutate({ month, ...previewData })} disabled={taxPreviewMutation.isPending} className="flex-1">
{taxPreviewMutation.isPending ? '计算中...' : '计算'}
</Button>
<Button variant="secondary" onClick={() => {
setPreviewData({ baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, specialDeduction: 0 })
setTaxResult(null)
}}>
</Button>
</div>
{taxResult && (
<div className="border rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
{taxResult.breakdown.map((item: any, i: number) => (
<div key={i} className={`flex justify-between text-xs ${i === taxResult.breakdown.length - 1 ? 'font-bold border-t pt-2 mt-2' : ''} ${item.value < 0 ? 'text-danger' : item.value > 0 && i < taxResult.breakdown.length - 1 ? 'text-gray-500' : ''}`}>
<span>{item.label}</span>
<span>{item.value < 0 ? `${fmt(Math.abs(item.value))}` : `¥${fmt(item.value)}`}</span>
</div>
))}
{taxResult.ytdPayslipCount > 0 && (
<div className="text-xs text-gray-500 mt-2">{taxResult.ytdPayslipCount}</div>
)}
</div>
)}
</div>
</Modal>
)}
</div>
)
}
+286
View File
@@ -0,0 +1,286 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Settings as Plus, X } from 'lucide-react'
import PageGuide from '../../components/ui/PageGuide'
import { payrollApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal'
// 金额格式化:保留两位小数 + 千分位
// ========== 薪酬模版管理 ==========
export function TemplateManager() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showForm, setShowForm] = useState(false)
const [editingItem, setEditingItem] = useState<any>(null)
const [form, setForm] = useState({
name: '',
code: '',
type: 'INPUT' as 'INPUT' | 'CALCULATED',
formula: '',
order: 99,
isEditable: true,
})
const { data: items, isLoading } = useQuery<any[]>({
queryKey: ['payslip-template'],
queryFn: async () => {
return await payrollApi.template()
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => payrollApi.removeTemplateItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
toast.success('已删除')
},
onError: () => toast.error('删除失败'),
})
const createMutation = useMutation({
mutationFn: (data: any) => payrollApi.createTemplateItem(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
setShowForm(false)
toast.success('已新增薪酬项')
},
onError: () => toast.error('新增失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => payrollApi.updateTemplateItem(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslip-template'] })
setShowForm(false)
setEditingItem(null)
toast.success('已更新')
},
onError: () => toast.error('更新失败'),
})
/** 打开新增表单 */
const handleAdd = () => {
setEditingItem(null)
setForm({ name: '', code: '', type: 'INPUT', formula: '', order: items?.length ? items.length + 1 : 99, isEditable: true })
setShowForm(true)
}
/** 打开编辑表单 */
const handleEdit = (item: any) => {
setEditingItem(item)
setForm({
name: item.name,
code: item.code,
type: item.type,
formula: item.formula || '',
order: item.order,
isEditable: item.isEditable,
})
setShowForm(true)
}
/** 提交表单 */
const handleSubmit = () => {
if (!form.name.trim() || !form.code.trim()) {
toast.error('名称和字段代码不能为空')
return
}
const payload = {
name: form.name.trim(),
type: form.type,
formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null,
order: Number(form.order),
isEditable: form.isEditable,
}
if (editingItem) {
updateMutation.mutate({ id: editingItem.id, data: payload })
} else {
createMutation.mutate({ ...payload, code: form.code.trim() })
}
}
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-sm font-medium"></h2>
<span className="text-xs text-gray-500"></span>
</div>
<Button size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-4 text-gray-500">...</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2"></th>
<th className="py-2 px-2 text-right"></th>
</tr>
</thead>
<tbody>
{items?.map((item: any) => (
<tr key={item.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-2 text-gray-500">{item.order}</td>
<td className="py-2 px-2 text-sm font-medium">{item.name}</td>
<td className="py-2 px-2 text-gray-500 font-mono">{item.code}</td>
<td className="py-2 px-2">
<span className={`px-2 py-0.5 rounded text-xs ${item.type === 'INPUT' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>
{item.type === 'INPUT' ? '输入项' : '计算项'}
</span>
</td>
<td className="py-2 px-2 text-gray-500 font-mono">{item.formula || '—'}</td>
<td className="py-2 px-2">
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
{item.isEditable ? '可编辑' : '不可编辑'}
</span>
</td>
<td className="py-2 px-2">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(item)}
className="text-xs text-gray-500 hover:text-primary"
>
</button>
{!item.isDefault && (
<button
onClick={async () => {
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
deleteMutation.mutate(item.id)
}
}}
className="text-xs text-gray-500 hover:text-danger"
>
</button>
)}
{item.isDefault && <span className="text-xs text-gray-300"></span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-500 mt-3">
</p>
</Card>
{/* 新增/编辑弹窗 */}
{showForm && (
<Modal open onClose={() => { setShowForm(false); setEditingItem(null) }}>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium">{editingItem ? '编辑薪酬项' : '新增薪酬项'}</h3>
<button onClick={() => { setShowForm(false); setEditingItem(null) }} className="text-gray-500 hover:text-gray-700">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="如:交通补贴"
/>
</div>
<div>
<Label></Label>
<Input
type="text"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="如:transportAllowance"
disabled={!!editingItem}
/>
{editingItem && (
<p className="text-xs text-gray-500 mt-1"></p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value as 'INPUT' | 'CALCULATED' })}
disabled={!!editingItem}
>
<option value="INPUT"></option>
<option value="CALCULATED"></option>
</Select>
</div>
<div>
<Label></Label>
<Input
type="number"
value={form.order}
onChange={(e) => setForm({ ...form, order: Number(e.target.value) })}
/>
</div>
</div>
{form.type === 'CALCULATED' && (
<div>
<Label></Label>
<Input
type="text"
value={form.formula}
onChange={(e) => setForm({ ...form, formula: e.target.value })}
placeholder="如:baseSalary + overtimePay + allowance - deduction"
/>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
)}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.isEditable}
onChange={(e) => setForm({ ...form, isEditable: e.target.checked })}
/>
<span className="text-sm"></span>
</label>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleSubmit}
disabled={createMutation.isPending || updateMutation.isPending}
className="flex-1"
>
{createMutation.isPending || updateMutation.isPending ? '保存中...' : editingItem ? '保存修改' : '确认新增'}
</Button>
<Button variant="secondary" onClick={() => { setShowForm(false); setEditingItem(null) }}>
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+6 -6
View File
@@ -2,7 +2,8 @@
* 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除
*/
import { useEffect, useState } from 'react'
import { Search, Building2, Eye, Trash2, Edit2, Plus } from 'lucide-react'
import { Search, Trash2, Edit2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
import { Input, Select, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
@@ -36,7 +37,6 @@ export default function PlatformOrgs() {
})
const [creating, setCreating] = useState(false)
const [editAdmin, setEditAdmin] = useState({ name: '', phone: '', password: '' })
const [savingAdmin, setSavingAdmin] = useState(false)
const fetchOrgs = async () => {
setLoading(true)
@@ -94,13 +94,13 @@ export default function PlatformOrgs() {
setEditOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '保存失败')
toast.error(err.response?.data?.error?.message || '保存失败')
}
}
const handleCreate = async () => {
if (!createForm.name || !createForm.adminPhone || !createForm.adminPassword) {
alert('企业名称、管理员手机号、密码不能为空')
toast.error('企业名称、管理员手机号、密码不能为空')
return
}
setCreating(true)
@@ -114,7 +114,7 @@ export default function PlatformOrgs() {
})
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '创建失败')
toast.error(err.response?.data?.error?.message || '创建失败')
} finally {
setCreating(false)
}
@@ -127,7 +127,7 @@ export default function PlatformOrgs() {
setDeleteOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '删除失败')
toast.error(err.response?.data?.error?.message || '删除失败')
}
}
@@ -3,6 +3,7 @@
*/
import { useEffect, useState } from 'react'
import { Search, Ban, CheckCircle } from 'lucide-react'
import { toast } from 'sonner'
import { platformApi } from '../../lib/api-services'
import { Input, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
@@ -59,7 +60,7 @@ export default function PlatformUsers() {
await platformApi.toggleUser(user.id)
fetchUsers()
} catch (err: any) {
alert(err.response?.data?.error?.message || '操作失败')
toast.error(err.response?.data?.error?.message || '操作失败')
}
}
+3 -3
View File
@@ -2,12 +2,11 @@
* 员工 Hub 首页 — 员工端统一入口
* 展示个人概览、待办事项、快捷入口、公司公告
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
DollarSign, FileText, CalendarCheck, ScrollText,
TrendingUp, Clock, AlertCircle, ChevronRight,
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
AlertCircle, ChevronRight,
} from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
@@ -21,6 +20,7 @@ const QUICK_ACTIONS = [
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
]
+227
View File
@@ -0,0 +1,227 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CalendarClock, Plus, X, Clock, CheckCircle2, XCircle, RotateCcw, Send } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
const LEAVE_TYPE_MAP: Record<string, string> = {
SICK: '病假',
PERSONAL: '事假',
ANNUAL: '年假',
MATERNITY: '产假',
OTHER: '其他',
}
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待审批', color: 'bg-amber-50 text-amber-700', icon: <Clock className="w-3 h-3" /> },
APPROVED: { label: '已批准', color: 'bg-green-50 text-green-700', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已撤回', color: 'bg-gray-100 text-gray-500', icon: <RotateCcw className="w-3 h-3" /> },
}
const fmtDate = (d: string) => new Date(d).toLocaleDateString('zh-CN')
export default function MyLeave() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showForm, setShowForm] = useState(false)
const [formLeaveType, setFormLeaveType] = useState('PERSONAL')
const [formStartDate, setFormStartDate] = useState('')
const [formEndDate, setFormEndDate] = useState('')
const [formDays, setFormDays] = useState(1)
const [formReason, setFormReason] = useState('')
const { data: list = [], isLoading } = useQuery<any[]>({
queryKey: ['portal-leaves'],
queryFn: () => portalApi.myLeaves(),
})
const submitMutation = useMutation({
mutationFn: (data: any) => portalApi.submitLeave(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-leaves'] })
toast.success('休假申请已提交,等待审批')
setShowForm(false)
setFormLeaveType('PERSONAL'); setFormStartDate(''); setFormEndDate(''); setFormDays(1); setFormReason('')
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '提交失败'),
})
const cancelMutation = useMutation({
mutationFn: (id: string) => portalApi.cancelLeave(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portal-leaves'] })
toast.success('已撤回')
},
})
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<CalendarClock className="w-5 h-5 text-primary" />
<h1 className="text-base font-bold"></h1>
</div>
{/* 发起申请按钮 */}
<button
onClick={() => setShowForm(true)}
className="w-full bg-primary text-white rounded-lg py-3 flex items-center justify-center gap-2 text-sm font-medium active:opacity-80 transition-opacity"
>
<Plus className="w-4 h-4" />
</button>
{/* 待审批提醒 */}
{pendingCount > 0 && (
<div className="bg-amber-50 rounded-lg px-3 py-2 flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-600" />
<span className="text-xs text-amber-700"> {pendingCount} </span>
</div>
)}
{/* 申请列表 */}
{isLoading ? (
<div className="text-center py-12 text-gray-400 text-sm">...</div>
) : list.length === 0 ? (
<div className="bg-white rounded-lg p-8 text-center">
<p className="text-sm text-gray-400"></p>
</div>
) : (
<div className="space-y-2">
{list.map((item: any) => {
const st = STATUS_MAP[item.status] || STATUS_MAP.PENDING
return (
<div key={item.id} className="bg-white rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{LEAVE_TYPE_MAP[item.leaveType] || item.leaveType}</span>
<span className="text-xs text-gray-500">{item.days} </span>
</div>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${st.color}`}>
{st.icon}{st.label}
</span>
</div>
<div className="text-xs text-gray-600">
{fmtDate(item.startDate)} ~ {fmtDate(item.endDate)}
</div>
{item.reason && (
<div className="text-xs text-gray-500">{item.reason}</div>
)}
{item.approveRemark && (
<div className="text-xs text-gray-500 border-t pt-1 mt-1">
{item.approveRemark}
</div>
)}
{item.status === 'PENDING' && (
<button
onClick={async () => {
if (await confirm({ title: '撤回申请', message: '确认撤回该申请?' })) cancelMutation.mutate(item.id)
}}
className="text-xs text-gray-400 hover:text-amber-600 flex items-center gap-1"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</div>
)
})}
</div>
)}
{/* 申请表单弹窗 */}
{showForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowForm(false)}>
<div className="bg-white rounded-xl max-w-md w-full p-5 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold flex items-center gap-2">
<Send className="w-4 h-4 text-primary" />
</h3>
<button onClick={() => setShowForm(false)} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<select
value={formLeaveType}
onChange={(e) => setFormLeaveType(e.target.value)}
className="w-full h-10 rounded-lg border border-gray-200 bg-white px-3 text-sm"
>
{Object.entries(LEAVE_TYPE_MAP).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<input
type="date"
value={formStartDate}
onChange={(e) => setFormStartDate(e.target.value)}
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<input
type="date"
value={formEndDate}
onChange={(e) => setFormEndDate(e.target.value)}
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<input
type="number"
min={0.5}
step={0.5}
value={formDays}
onChange={(e) => setFormDays(Number(e.target.value))}
className="w-full h-10 rounded-lg border border-gray-200 px-3 text-sm"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<textarea
value={formReason}
onChange={(e) => setFormReason(e.target.value)}
placeholder="请简要说明请假原因"
rows={3}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm resize-none"
/>
</div>
<button
onClick={() => {
if (!formStartDate || !formEndDate) { toast.error('请选择日期'); return }
submitMutation.mutate({
leaveType: formLeaveType,
startDate: formStartDate,
endDate: formEndDate,
days: formDays,
reason: formReason,
})
}}
disabled={submitMutation.isPending}
className="w-full bg-primary text-white rounded-lg py-2.5 text-sm font-medium disabled:opacity-50"
>
{submitMutation.isPending ? '提交中...' : '提交申请'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -3,7 +3,7 @@
* 展示入职步骤进度、材料提交状态、待完成项
*/
import { useQuery } from '@tanstack/react-query'
import { Check, Clock, AlertCircle, FileText, Upload, User, Phone, Banknote } from 'lucide-react'
import { Check, Clock, AlertCircle, FileText, Upload, User, Banknote } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import { InlineAlert } from '../../components/ui/InlineAlert'
@@ -4,7 +4,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { UserX, Clock, Check, X, FileText } from 'lucide-react'
import { UserX, Clock, FileText } from 'lucide-react'
import { portalApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
+4 -6
View File
@@ -1,13 +1,11 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi, attachmentApi } from '../../lib/api-services'
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { Select } from "../../components/ui/Input"
import { Paperclip, Trash2, Eye, Download } from "lucide-react"
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
const queryClient = useQueryClient()
@@ -1,12 +1,9 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi, employeeApi } from '../../lib/api-services'
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 考勤/加班/培训合并组件 */
+9 -5
View File
@@ -1,14 +1,12 @@
import { QRCodeSVG } from "qrcode.react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi, attachmentApi, employeeApi } from '../../lib/api-services'
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { AlertTriangle, Paperclip, Trash2, Eye, Download } from "lucide-react"
import { fmt } from "./shared"
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
@@ -284,6 +282,12 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
)}
<p className="text-xs text-gray-400 mt-2">/7portal端填报0</p>
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
<span>{!profile.socialInsBase ? '社保' : '公积金'}/0</span>
</div>
)}
</div>
{/* 特殊状态 */}
@@ -1,11 +1,5 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { UserX, DollarSign, Building2 } from "lucide-react"
import { fmt, terminateReasonMap } from "./shared"
import { CityHistoryTab } from "./PayslipSocialInfo"
+5 -6
View File
@@ -1,16 +1,16 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { employeeApi } from '../../lib/api-services'
import { useConfirm } from '../../hooks/useConfirm'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download } from "lucide-react"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
@@ -281,7 +281,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
)}
</div>
<button
onClick={() => { if (confirm('确定删除此合同记录?')) deleteContractMutation.mutate(c.id) }}
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
>
@@ -308,7 +308,6 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : ''
const isImage = mime.startsWith('image/')
const isPdf = mime === 'application/pdf'
const previewable = isImage || isPdf
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
@@ -1,13 +1,10 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { AlertTriangle, Check } from "lucide-react"
// ========== 违纪记录管理 ==========
@@ -4,8 +4,8 @@
* 可被 Roster、Termination、SocialInsurance 等页面复用
*/
import { useState, ReactNode } from 'react'
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, FileText, AlertTriangle } from 'lucide-react'
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, AlertTriangle } from 'lucide-react'
import { fmt, DetailTab, TAB_GROUPS } from './shared'
interface EmployeeSummary {
id: string
+2 -6
View File
@@ -1,14 +1,10 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useQuery } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import { useAuthStore } from "../../store/authStore"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { AlertTriangle, Scale } from "lucide-react"
// ========== 仲裁证据链 ==========
@@ -1,21 +1,17 @@
import { useState, useRef } from "react"
import { useState } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { socialInsuranceApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' }
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
return (
<div className="space-y-3">
<div className="flex gap-1">
@@ -1,13 +1,10 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
import { AlertTriangle, Check } from "lucide-react"
// ========== 绩效记录管理 ==========
@@ -1,12 +1,9 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { FileText, AlertTriangle, Scale, X, Printer, Calculator, Shield } from "lucide-react"
import { fmt } from "./shared"
import { generateEvidenceText } from "./EvidenceChain"
+3 -5
View File
@@ -1,12 +1,10 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { rosterApi, socialInsuranceApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { AlertTriangle, Briefcase, FileSignature } from "lucide-react"
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
import { fmt } from "./shared"
@@ -0,0 +1,270 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../../hooks/useConfirm'
import { Plus, Settings as SettingsIcon, X, Shield } from 'lucide-react'
import { commercialInsuranceApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import { InlineAlert } from '../../components/ui/InlineAlert'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
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 DEFAULT_PLAN = {
name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0,
effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '',
}
/**
* Tab
*
*/
export default function CommercialInsuranceTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [showAddPlan, setShowAddPlan] = useState(false)
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-plans'],
queryFn: async () => {
return await commercialInsuranceApi.plans()
},
})
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await commercialInsuranceApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return commercialInsuranceApi.savePlan(data, editingPlan.id) as any
}
return commercialInsuranceApi.savePlan(data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ ...DEFAULT_PLAN })
toast.success(editingPlan ? '商险方案已更新' : '商险方案已创建')
},
onError: () => toast.error('保存失败'),
})
const deletePlanMutation = useMutation({
mutationFn: (id: string) => commercialInsuranceApi.removePlan(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 <Card><div className="text-center py-8 text-gray-400">...</div></Card>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium"></h2>
</div>
<Button size="sm" onClick={() => { setEditingPlan(null); setNewPlan({ ...DEFAULT_PLAN }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<InlineAlert type="info">
</InlineAlert>
{plans.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan: any) => {
const typeCfg = INSURANCE_TYPES[plan.type] || INSURANCE_TYPES.OTHER
const isSelected = selectedPlanId === plan.id
return (
<Card
key={plan.id}
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
>
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
<div className="flex items-start justify-between mb-2">
<div>
<span className={`px-2 py-0.5 rounded text-xs ${typeCfg.color}`}>{typeCfg.label}</span>
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认删除', message: `确定删除商险方案「${plan.name}」吗?` })) {
deletePlanMutation.mutate(plan.id)
}
}}>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.provider}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700 font-mono">{plan.policyNo || '—'}</span></div>
<div className="flex justify-between"><span>()</span><span className="text-gray-700">¥{fmt(plan.premium)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.coverageAmount)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.effectiveFrom} ~ {plan.effectiveTo || '长期'}</span></div>
</div>
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
</div>
</Card>
)
})}
</div>
)}
{selectedPlanId && (
<Card>
<h3 className="text-sm font-medium mb-3">{enrollments.length}</h3>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2 text-gray-400 font-mono text-xs">{e.idCardMasked || '—'}</td>
<td className="py-2 text-right">¥{fmt(e.premium || 0)}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '有效' : '已终止'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{editingPlan ? '编辑商险方案' : '新增商险方案'}</h3>
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度员工意外险" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.type}
onChange={(e) => setNewPlan({ ...newPlan, type: e.target.value })}
>
{Object.entries(INSURANCE_TYPES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label> *</Label>
<Input value={newPlan.provider} onChange={(e) => setNewPlan({ ...newPlan, provider: e.target.value })} placeholder="如:中国人寿" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={newPlan.policyNo} onChange={(e) => setNewPlan({ ...newPlan, policyNo: e.target.value })} placeholder="保单编号" />
</div>
<div>
<Label>/</Label>
<Input type="number" value={newPlan.premium} onChange={(e) => setNewPlan({ ...newPlan, premium: parseFloat(e.target.value) || 0 })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.coverageAmount} onChange={(e) => setNewPlan({ ...newPlan, coverageAmount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveTo} onChange={(e) => setNewPlan({ ...newPlan, effectiveTo: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveFrom} onChange={(e) => setNewPlan({ ...newPlan, effectiveFrom: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={newPlan.description} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="保障范围、免赔额等" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
{savePlanMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
@@ -0,0 +1,154 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useMutation } from '@tanstack/react-query'
import { socialInsuranceApi } from '../../lib/api-services'
import { Input } from '../../components/ui/Input'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 月度办理社保行组件(可展开查看各险种明细,支持修改基数) */
export function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('social', i.recordId, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<>
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">
{editing ? (
<span onClick={(e) => e.stopPropagation()} className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={(e) => { e.stopPropagation(); setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
{expanded && d && (
<tr className="bg-gray-50/50">
<td colSpan={8} className="py-2 px-8">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-400">
<th className="py-1 text-left"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
<th className="py-1 text-right"></th>
</tr>
</thead>
<tbody>
{d.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1">{item.name}</td>
<td className="py-1 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1 text-right text-gray-500">{item.empRate > 0 ? `${item.empRate}%` : '-'}</td>
<td className="py-1 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1 text-right">{item.empAmount > 0 ? `¥${fmt(item.empAmount)}` : '-'}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
/** 月度办理公积金行组件(支持修改基数) */
export function MonthlyHousingRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => socialInsuranceApi.correctRecord('housing', i.recordId, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<tr className="border-b last:border-0 hover:bg-gray-50">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">
{editing ? (
<span className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => { setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
<td className="py-1.5 text-gray-400 text-xs">{type === 'add' ? `${i.startMonth}` : type === 'sub' ? `${i.endMonth}` : `${i.startMonth} ~ ${i.endMonth || '在保'}`}</td>
</tr>
)
}
@@ -0,0 +1,372 @@
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Upload, Download, X, ChevronRight } from 'lucide-react'
import { socialInsuranceApi } from '../../lib/api-services'
import { useAuthStore } from '../../store/authStore'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
import PageGuide from '../../components/ui/PageGuide'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
/** 专项附加扣除按月录入组件 */
export default function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<string | null>(null)
const [editForm, setEditForm] = useState<any>(null)
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<any>(null)
const [importing, setImporting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [unrecordedSearch, setUnrecordedSearch] = useState('')
const [showUnrecorded, setShowUnrecorded] = useState(false)
const [unrecordedPage, setUnrecordedPage] = useState(1)
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['special-deduction', month],
queryFn: async () => {
return await socialInsuranceApi.specialDeductionBatch(month)
},
})
const { data: employees = [] } = useQuery<any[]>({
queryKey: ['active-social-employees', month],
queryFn: async () => {
const res = await socialInsuranceApi.activeDeclaration(month) as any
return (res?.items || []).map((item: any) => ({
id: item.employeeId,
name: item.name,
department: item.department,
}))
},
})
const prevMonth = (() => {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
const { data: prevRecords = [] } = useQuery<any[]>({
queryKey: ['special-deduction', prevMonth],
queryFn: async () => {
return await socialInsuranceApi.specialDeductionBatch(prevMonth)
},
})
const saveMutation = useMutation({
mutationFn: (data: any) => socialInsuranceApi.saveSpecialDeduction({ ...data, month }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
setEditing(null)
setEditForm(null)
},
})
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
const unrecordedAll = employees.filter((e: any) => !recordMap.has(e.id))
const filteredUnrecorded = unrecordedSearch
? unrecordedAll.filter((e: any) => e.name.includes(unrecordedSearch) || (e.department || '').includes(unrecordedSearch))
: unrecordedAll
const unrecordedPageSize = 20
const unrecordedTotalPages = Math.ceil(filteredUnrecorded.length / unrecordedPageSize)
const pagedUnrecorded = filteredUnrecorded.slice((unrecordedPage - 1) * unrecordedPageSize, unrecordedPage * unrecordedPageSize)
const startEdit = (empId: string, existing?: any) => {
setEditing(empId)
setEditForm(existing ? {
children: existing.children,
elderly: existing.elderly,
housing: existing.housing,
education: existing.education,
infant: existing.infant,
remark: existing.remark,
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
}
const batchCopyMutation = useMutation({
mutationFn: async () => {
let copied = 0
for (const prev of prevRecords) {
await socialInsuranceApi.saveSpecialDeduction({
employeeId: prev.employeeId,
month,
children: prev.children || 0,
elderly: prev.elderly || 0,
housing: prev.housing || 0,
education: prev.education || 0,
infant: prev.infant || 0,
remark: prev.remark || '',
})
copied++
}
return copied
},
onSuccess: (copied: number) => {
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
if (copied > 0) {
toast.success(`已复制 ${copied}${prevMonth} 的扣除数据到 ${month}`)
} else {
toast.info(`${prevMonth} 无可复制的扣除数据`)
}
},
onError: () => toast.error('复制上月数据失败'),
})
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
return (
<Card>
<div className="mb-3">
<PageGuide>
/使
</PageGuide>
</div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => batchCopyMutation.mutate()}
disabled={batchCopyMutation.isPending || prevRecords.length === 0}
>
{batchCopyMutation.isPending ? '复制中...' : `复制上月(${prevMonth})`}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setShowImport(true)}
>
<Upload className="w-3.5 h-3.5 mr-1" />
</Button>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : (
<div className="space-y-3">
{records.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium text-right"></th>
<th className="py-2 font-medium"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
{editing === r.employeeId ? (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
<td className="py-1">
<div className="flex gap-1">
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</td>
</>
) : (
<>
<td className="py-1.5">{r.employee?.name}</td>
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}></button></td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{unrecordedAll.length > 0 && (
<div className="border-t pt-3">
<button
className="flex items-center gap-1 text-xs font-medium text-gray-500 mb-2"
onClick={() => setShowUnrecorded(!showUnrecorded)}
>
<ChevronRight className={`w-3 h-3 transition-transform ${showUnrecorded ? 'rotate-90' : ''}`} />
{unrecordedAll.length}
</button>
{showUnrecorded && (
<>
<Input
placeholder="搜索姓名或部门..."
value={unrecordedSearch}
onChange={(e) => { setUnrecordedSearch(e.target.value); setUnrecordedPage(1) }}
className="mb-2 !h-8 !text-xs"
/>
<div className="flex flex-wrap gap-2">
{pagedUnrecorded.map((e: any) => (
<button
key={e.id}
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
onClick={() => startEdit(e.id)}
>
{e.name}{e.department}
</button>
))}
</div>
{unrecordedTotalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-2">
<button
onClick={() => setUnrecordedPage(p => Math.max(1, p - 1))}
disabled={unrecordedPage === 1}
className="text-xs text-gray-400 disabled:opacity-30"
></button>
<span className="text-xs text-gray-400">{unrecordedPage}/{unrecordedTotalPages}</span>
<button
onClick={() => setUnrecordedPage(p => Math.min(unrecordedTotalPages, p + 1))}
disabled={unrecordedPage === unrecordedTotalPages}
className="text-xs text-gray-400 disabled:opacity-30"
></button>
</div>
)}
</>
)}
</div>
)}
{editing && !recordMap.has(editing) && (
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
<h3 className="text-xs font-medium"> {employees.find((e: any) => e.id === editing)?.name}</h3>
<div className="grid grid-cols-5 gap-2">
<div><Label></Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
</div>
<div className="flex items-center gap-3">
<div className="flex-1"><Label></Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
<div className="text-sm text-gray-500 pt-5"><span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}></Button>
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}></Button>
</div>
</div>
)}
{records.length === 0 && unrecordedAll.length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
)}
{showImport && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setShowImport(false)}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()} className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"> {month}</h2>
<button onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
const res = await fetch(`${baseURL}/import/special-deduction/template`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '专项附加扣除导入模板.xlsx'
a.click()
URL.revokeObjectURL(url)
} catch { toast.error('下载模板失败') }
}}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" className="hidden" id="special-deduction-import-file" onChange={(e) => { setImportFile(e.target.files?.[0] || null); setImportResult(null) }} />
<label htmlFor="special-deduction-import-file" className="cursor-pointer text-xs text-primary hover:underline">
{importFile ? importFile.name : '点击选择 Excel 文件'}
</label>
</div>
{importResult && (
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
<div className="font-medium"></div>
<div> {importResult.updated} {importResult.skipped} {importResult.total} </div>
{importResult.errors?.length > 0 && (
<div className="mt-1 pt-1 border-t border-green-200">
{importResult.errors.slice(0, 5).map((e: string, i: number) => <div key={i} className="text-amber-600">{e}</div>)}
{importResult.errors.length > 5 && <div className="text-amber-600">... {importResult.errors.length - 5} </div>}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => { setShowImport(false); setImportFile(null); setImportResult(null) }}></Button>
<Button size="sm" onClick={async () => {
if (!importFile) return toast.error('请选择文件')
setImporting(true)
setImportResult(null)
try {
const token = useAuthStore.getState().accessToken
const formData = new FormData()
formData.append('file', importFile)
formData.append('month', month)
const res = await fetch('/api/v1/import/special-deduction', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
})
const data = await res.json()
if (!data.success) { toast.error(data.error?.message || '导入失败') }
else {
setImportResult(data.data)
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
toast.success(`导入完成:成功 ${data.data.updated}`)
}
} catch (e: any) { toast.error(e?.message || '导入失败') }
finally { setImporting(false) }
}} disabled={!importFile || importing}>{importing ? '导入中...' : '开始导入'}</Button>
</div>
</div>
</div>
</Card>
</div>
)}
</Card>
)
}
@@ -6,7 +6,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { TrendingUp, Save, History, Download, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
import { TrendingUp, Save, History, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import { dashboardApi } from '../../lib/api-services'
import Card from '../../components/ui/Card'
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string