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:
freedakgmail
2026-07-24 07:04:08 +08:00
parent 559a567b9b
commit f1c72f3eb0
20 changed files with 2031 additions and 260 deletions
-1
View File
@@ -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'
+223 -4
View File
@@ -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>
)
}
+169 -19
View File
@@ -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)}
+197 -4
View File
@@ -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>
)
}
+38
View File
@@ -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">
+39 -15
View File
@@ -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 ========== */}
+9
View File
@@ -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