feat: 完成优化-4全部任务 + 多城市社保 + pgvector修复
- AIAssistant: 会话历史保存/加载/删除,风险预测支持范围筛选,审查结果保存到员工档案 - Dashboard: 待办批量操作,风险分布可下钻,刷新按钮Tab级联,薪税tab导出Excel - SocialInsurance: 多城市社保/公积金配置支持,城市选择器 - Roster: 新增参保城市字段 - risk.service: 修复风险项去重逻辑(用employeeId:type:actionUrl替代含动态天数的title) - payroll.routes: 修复OvertimeRecord/Payslip字段名错误 - pgvector: 从源码编译安装x86_64版本兼容postgresql@15 - 优化-4文档: 全部8项标记为已完成
This commit is contained in:
@@ -7,7 +7,6 @@ import Login from './pages/auth/Login'
|
||||
import Register from './pages/auth/Register'
|
||||
import ForgotPassword from './pages/auth/ForgotPassword'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Contracts from './pages/Contracts'
|
||||
import Money from './pages/Money'
|
||||
import SocialInsurance from './pages/SocialInsurance'
|
||||
import Roster from './pages/Roster'
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic } from 'lucide-react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
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'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case'
|
||||
|
||||
@@ -61,19 +63,72 @@ export default function AIAssistant() {
|
||||
}
|
||||
|
||||
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 scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recognitionRef = useRef<any>(null)
|
||||
const saveTimerRef = useRef<any>(null)
|
||||
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/conversations') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const deleteConvMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
|
||||
})
|
||||
|
||||
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 = messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'
|
||||
if (currentConvId) {
|
||||
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
|
||||
} else {
|
||||
const res = await api.post('/ai/conversations', { title, messages }) as any
|
||||
if (res.data?.id) {
|
||||
setCurrentConvId(res.data.id)
|
||||
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [messages])
|
||||
|
||||
const loadConversation = async (id: string) => {
|
||||
try {
|
||||
const res = await api.get(`/ai/conversations/${id}`) as any
|
||||
if (res.data?.messages) {
|
||||
setMessages(res.data.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) {
|
||||
@@ -166,6 +221,28 @@ function ChatTab() {
|
||||
|
||||
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>
|
||||
{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}</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'}`}>
|
||||
@@ -216,11 +293,29 @@ function ChatTab() {
|
||||
function PredictTab() {
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [scope, setScope] = useState('all')
|
||||
const [riskType, setRiskType] = useState('all')
|
||||
const [department, setDepartment] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get('/ai/predict') as any
|
||||
const params: Record<string, string> = {}
|
||||
if (scope === 'department' && department) params.department = department
|
||||
if (scope === 'employee' && employeeId) params.employeeId = employeeId
|
||||
if (riskType !== 'all') params.riskType = riskType
|
||||
const res = await api.get('/ai/predict', { params }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
@@ -239,6 +334,46 @@ function PredictTab() {
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
</div>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</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>
|
||||
<Label>风险类型</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>
|
||||
<Label>部门</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>
|
||||
<Label>员工</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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
@@ -257,6 +392,16 @@ function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleReview = async () => {
|
||||
if (!contractText.trim()) return
|
||||
@@ -272,6 +417,18 @@ function ReviewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
alert('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
alert('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -295,10 +452,30 @@ function ReviewTab() {
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">审查结果</h3>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -307,6 +484,16 @@ 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 { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!scenario.trim()) return
|
||||
@@ -322,6 +509,18 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
alert('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
alert('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -345,10 +544,30 @@ function CaseTab() {
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<h3 className="font-medium mb-3">分析结果</h3>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle } from 'lucide-react'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -36,6 +36,8 @@ export default function Dashboard() {
|
||||
const [todoPageSize, setTodoPageSize] = useState(10)
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [drillDownType, setDrillDownType] = useState<string | null>(null)
|
||||
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
@@ -62,6 +64,46 @@ export default function Dashboard() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
})
|
||||
|
||||
const batchResolveMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-resolve', { ids }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setSelectedIds(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
const batchIgnoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-ignore', { ids }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setSelectedIds(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportPayroll = () => {
|
||||
const month = payroll?.month || new Date().toISOString().slice(0, 7)
|
||||
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = (ids: string[]) => {
|
||||
setSelectedIds(prev => {
|
||||
const allSelected = ids.every(id => prev.has(id))
|
||||
const next = new Set(prev)
|
||||
if (allSelected) ids.forEach(id => next.delete(id))
|
||||
else ids.forEach(id => next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'ONBOARDING') || []
|
||||
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
|
||||
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
|
||||
@@ -118,9 +160,9 @@ export default function Dashboard() {
|
||||
<h1 className="text-xs font-medium">{data.greeting}</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? '刷新中...' : '刷新'}
|
||||
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +209,33 @@ export default function Dashboard() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 合同到期预警 */}
|
||||
{expiringContracts && expiringContracts.length > 0 && (
|
||||
<Link to="/roster?contractStatus=expiring">
|
||||
<Card className="border-danger/30 bg-danger/5 hover:bg-danger/10 transition-colors cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-danger">合同到期预警</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
|
||||
<span key={c.employeeId}>
|
||||
{i > 0 && '、'}
|
||||
{c.employeeName}
|
||||
<span className="text-danger ml-1">({c.daysLeft}天)</span>
|
||||
</span>
|
||||
))}
|
||||
{expiringContracts.length > 3 && <span className="text-gray-400"> 等{expiringContracts.length}人</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-danger" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -191,19 +260,57 @@ export default function Dashboard() {
|
||||
<Card>
|
||||
<h2 className="font-medium mb-3">风险分布</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-primary">{data.riskDistribution.contract}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">合同风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{data.riskDistribution.contract > 0 && <ChevronRight className="w-3 h-3 text-primary mx-auto mt-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-warning">{data.riskDistribution.salary}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">薪资风险</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{data.riskDistribution.salary > 0 && <ChevronRight className="w-3 h-3 text-warning mx-auto mt-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
|
||||
className={`text-center p-3 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<div className="text-lg font-bold text-danger">{data.riskDistribution.termination}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">解聘风险</div>
|
||||
</div>
|
||||
{data.riskDistribution.termination > 0 && <ChevronRight className="w-3 h-3 text-danger mx-auto mt-1" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下钻明细 */}
|
||||
{drillDownType && (
|
||||
<div className="mt-3 border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
{drillDownType === 'CONTRACT' ? '合同' : drillDownType === 'SALARY' ? '薪资' : '解聘'}风险明细
|
||||
</span>
|
||||
<button onClick={() => setDrillDownType(null)} className="text-xs text-gray-400 hover:text-gray-600">收起</button>
|
||||
</div>
|
||||
{data.topRisks.filter(r => r.type === drillDownType).length > 0 ? (
|
||||
data.topRisks.filter(r => r.type === drillDownType).map((r) => (
|
||||
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
|
||||
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-gray-800">{r.title}</div>
|
||||
{r.employeeName && <div className="text-gray-400">{r.employeeName}</div>}
|
||||
</div>
|
||||
<ArrowRight className="w-3 h-3 text-gray-400" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 text-center py-2">暂无高风险项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
@@ -213,9 +320,14 @@ export default function Dashboard() {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
|
||||
<Download className="w-4 h-4 mr-1" />导出
|
||||
</Button>
|
||||
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
查看明细 <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{payroll && payroll.payslipCount > 0 ? (
|
||||
@@ -321,6 +433,36 @@ export default function Dashboard() {
|
||||
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
|
||||
) : (
|
||||
<>
|
||||
{/* 批量操作栏 */}
|
||||
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
|
||||
<button
|
||||
onClick={() => toggleSelectAll(filteredTodos.map(t => t.id))}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{filteredTodos.every(t => selectedIds.has(t.id)) ? '取消全选' : '全选'}
|
||||
</button>
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">已选 {selectedIds.size} 项</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => batchResolveMutation.mutate([...selectedIds])}
|
||||
disabled={batchResolveMutation.isPending}
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />批量完成
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => batchIgnoreMutation.mutate([...selectedIds])}
|
||||
disabled={batchIgnoreMutation.isPending}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />批量忽略
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
|
||||
<div className="space-y-2">
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
|
||||
@@ -328,13 +470,21 @@ export default function Dashboard() {
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(todo.id)}
|
||||
onChange={() => toggleSelect(todo.id)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => resolveMutation.mutate(todo.id)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History } from 'lucide-react'
|
||||
import { Calculator, AlertCircle, Info, Save, Check, Upload, Bell, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, History, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
@@ -783,6 +784,8 @@ function OvertimeCalculator() {
|
||||
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>({
|
||||
@@ -828,6 +831,33 @@ function OvertimeCalculator() {
|
||||
},
|
||||
})
|
||||
|
||||
// 更新单条加班记录
|
||||
const updateOvertimeMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||
api.put(`/payroll/overtime/${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
|
||||
@@ -1046,6 +1076,7 @@ function OvertimeCalculator() {
|
||||
<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>
|
||||
@@ -1053,9 +1084,46 @@ function OvertimeCalculator() {
|
||||
<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>
|
||||
<td className="py-2 text-right">{r.weekdayHours || '-'}</td>
|
||||
<td className="py-2 text-right">{r.weekendHours || '-'}</td>
|
||||
<td className="py-2 text-right">{r.holidayHours || '-'}</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-400">待计算</span>}
|
||||
</td>
|
||||
@@ -1066,6 +1134,37 @@ function OvertimeCalculator() {
|
||||
<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-400 hover:text-gray-600"
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!r.batchId && (
|
||||
<button
|
||||
onClick={() => startEdit(r)}
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
title="编辑"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1099,6 +1198,16 @@ function PayslipManager() {
|
||||
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 [previewEmployeeId, setPreviewEmployeeId] = useState('')
|
||||
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],
|
||||
@@ -1123,6 +1232,16 @@ function PayslipManager() {
|
||||
},
|
||||
})
|
||||
|
||||
const taxPreviewMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/payroll/tax-preview', 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
|
||||
|
||||
@@ -1140,6 +1259,12 @@ function PayslipManager() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowTaxPreview(true)}
|
||||
>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
税率试算
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
@@ -1210,6 +1335,74 @@ function PayslipManager() {
|
||||
</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-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">基本工资</label>
|
||||
<Input type="number" value={previewData.baseSalary || ''} onChange={(e) => setPreviewData({ ...previewData, baseSalary: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">加班费</label>
|
||||
<Input type="number" value={previewData.overtimePay || ''} onChange={(e) => setPreviewData({ ...previewData, overtimePay: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">津贴</label>
|
||||
<Input type="number" value={previewData.allowance || ''} onChange={(e) => setPreviewData({ ...previewData, allowance: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">奖金</label>
|
||||
<Input type="number" value={previewData.bonus || ''} onChange={(e) => setPreviewData({ ...previewData, bonus: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">扣款</label>
|
||||
<Input type="number" value={previewData.deduction || ''} onChange={(e) => setPreviewData({ ...previewData, deduction: Number(e.target.value) })} placeholder="请输入" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">专项附加扣除</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-400 mt-2">注:已累计{taxResult.ytdPayslipCount}条工资条计算个税</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -982,6 +982,24 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string;
|
||||
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
alert('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
alert(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setForm({ ...form, attachmentUrl: event.target?.result as string })
|
||||
@@ -1668,6 +1686,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', phone: '',
|
||||
city: '北京',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||||
@@ -1826,6 +1845,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>参保城市</Label><select className="w-full text-xs border rounded px-2 py-1.5" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}><option value="北京">北京</option><option value="上海">上海</option><option value="广州">广州</option><option value="深圳">深圳</option><option value="杭州">杭州</option></select></div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
@@ -1927,6 +1947,23 @@ function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attac
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
alert('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
alert(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const fileUrl = event.target?.result as string
|
||||
@@ -1957,6 +1994,7 @@ function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attac
|
||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-xs">支持 PDF/JPG/PNG,最大 10MB</span>
|
||||
</div>
|
||||
{attachments?.length ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
@@ -36,26 +37,35 @@ export default function SocialInsurance() {
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
})
|
||||
|
||||
const { data: config } = useQuery<any>({
|
||||
queryKey: ['social-config'],
|
||||
// 获取城市列表
|
||||
const { data: cities = [] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config') as any
|
||||
const res = await api.get('/social/config/cities') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: config } = useQuery<any>({
|
||||
queryKey: ['social-config', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingConfig } = useQuery<any>({
|
||||
queryKey: ['housing-config'],
|
||||
queryKey: ['housing-config', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/housing-config') as any
|
||||
const res = await api.get('/social/housing-config', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['social-config-versions'],
|
||||
queryKey: ['social-config-versions', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/versions') as any
|
||||
const res = await api.get('/social/config/versions', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showVersions && tab === 'social',
|
||||
@@ -174,19 +184,19 @@ export default function SocialInsurance() {
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`),
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
|
||||
alert('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`),
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
|
||||
alert('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
@@ -237,8 +247,8 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['social', 'housing', 'monthly'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
@@ -250,6 +260,20 @@ export default function SocialInsurance() {
|
||||
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-xs text-gray-500">城市:</label>
|
||||
<select
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
||||
|
||||
@@ -105,6 +105,15 @@ export interface DashboardData {
|
||||
salary: number
|
||||
termination: number
|
||||
}
|
||||
topRisks: {
|
||||
id: string
|
||||
type: string
|
||||
level: string
|
||||
title: string
|
||||
description: string
|
||||
employeeName: string | null
|
||||
actionUrl: string
|
||||
}[]
|
||||
aiPrediction: {
|
||||
risks: unknown[]
|
||||
suggestion: string
|
||||
|
||||
Reference in New Issue
Block a user