feat: AI历史记录、企业信息seed、员工状态自动刷新、风险预测数据准确性优化
- 智能问答/风险预测/合同审查/案例匹配 均支持历史记录保存、加载、删除 - 后端 predict/predict-stream 补充离职记录详情和预计算工龄/天数,避免AI计算错误 - Organization 增加 contactName/contactPhone 字段,seed 企业信息 - Settings 页面修复 orgData/usersData 取值路径错误 - Roster/Termination/Contracts mutation 增加 roster cache invalidation - 员工档案页面 tab 固定、内容区域滚动到窗口底部 - 解聘证据链显示逻辑修复(协商一致离职 vs 员工主动离职)
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -13,6 +14,64 @@ import Modal from '../components/ui/Modal'
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
|
||||
|
||||
// 通用 AI 历史记录 hook
|
||||
function useAIHistory(type: 'predict' | 'review' | 'case') {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = [`ai-history-${type}`]
|
||||
|
||||
const { data: history } = useQuery<any[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/ai/conversations?type=${type}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ title, input, result }: { title: string; input: string; result: string }) => {
|
||||
const res = await api.post('/ai/conversations', {
|
||||
title: `${type}:${title}`,
|
||||
messages: [{ role: 'user', content: input }, { role: 'assistant', content: result }],
|
||||
}) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
})
|
||||
|
||||
const loadHistory = useCallback(async (id: string) => {
|
||||
const res = await api.get(`/ai/conversations/${id}`) as any
|
||||
return res.data
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
@@ -90,7 +149,7 @@ function ChatTab() {
|
||||
const { data: conversations } = useQuery<any[]>({
|
||||
queryKey: ['ai-conversations'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/conversations') as any
|
||||
const res = await api.get('/ai/conversations?type=chat') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
@@ -109,7 +168,7 @@ function ChatTab() {
|
||||
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) || '新对话'
|
||||
const title = `chat:${messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'}`
|
||||
if (currentConvId) {
|
||||
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
|
||||
} else {
|
||||
@@ -280,7 +339,7 @@ function ChatTab() {
|
||||
<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="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>
|
||||
@@ -372,6 +431,9 @@ function PredictTab() {
|
||||
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 { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -384,44 +446,143 @@ function PredictTab() {
|
||||
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
|
||||
|
||||
const fetchPrediction = async () => {
|
||||
if (loading) return
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
|
||||
try {
|
||||
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)
|
||||
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 || '请求失败')
|
||||
}
|
||||
|
||||
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('**出错了**')) {
|
||||
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) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
if (err.name === 'AbortError') return
|
||||
setResult(`**出错了**:${err.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrediction()
|
||||
}, [])
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="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="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
|
||||
<div>
|
||||
<Label>预测范围</Label>
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap mt-3">
|
||||
<div className="min-w-[120px]">
|
||||
<Button size="sm" onClick={fetchPrediction} 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>
|
||||
<Label>风险类型</Label>
|
||||
<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>
|
||||
@@ -430,8 +591,8 @@ function PredictTab() {
|
||||
</Select>
|
||||
</div>
|
||||
{scope === 'department' && (
|
||||
<div>
|
||||
<Label>部门</Label>
|
||||
<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>)}
|
||||
@@ -439,8 +600,8 @@ function PredictTab() {
|
||||
</div>
|
||||
)}
|
||||
{scope === 'employee' && (
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<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>)}
|
||||
@@ -449,16 +610,33 @@ function PredictTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && !result && (
|
||||
<div className="flex items-center gap-2 text-gray-400 py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 分析中...
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> 正在分析企业用工风险...
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="prose prose-sm max-w-none mt-4 overflow-x-auto
|
||||
[&_table]:border-collapse [&_table]:w-full [&_table]:text-xs [&_table]:min-w-[600px]
|
||||
[&_th]:border [&_th]:border-gray-300 [&_th]:px-2 [&_th]:py-1 [&_th]:bg-gray-50 [&_th]:font-medium [&_th]:whitespace-nowrap
|
||||
[&_td]:border [&_td]:border-gray-300 [&_td]:px-2 [&_td]:py-1 [&_td]:align-top
|
||||
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2
|
||||
[&_h3]:text-xs [&_h3]:font-medium [&_h3]:mt-3 [&_h3]:mb-1
|
||||
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:text-xs
|
||||
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:text-xs
|
||||
[&_strong]:font-semibold
|
||||
[&_p]:text-xs [&_p]:leading-relaxed
|
||||
[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:pl-3 [&_blockquote]:text-gray-600 [&_blockquote]:text-xs [&_blockquote]:my-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{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-8 text-gray-400">
|
||||
<Sparkles className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-xs">选择筛选条件后点击「开始预测」按钮进行AI风险分析</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}>刷新预测</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -469,6 +647,8 @@ function ReviewTab() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSaveModal, setShowSaveModal] = useState(false)
|
||||
const [saveEmployeeId, setSaveEmployeeId] = useState('')
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review')
|
||||
|
||||
const { data: employees } = useQuery<any[]>({
|
||||
queryKey: ['roster-list'],
|
||||
@@ -485,6 +665,11 @@ function ReviewTab() {
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data)
|
||||
// 自动保存到历史
|
||||
if (res.data && !res.data.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 {
|
||||
@@ -492,6 +677,19 @@ function ReviewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -516,7 +714,15 @@ function ReviewTab() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="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-[200px] resize-y"
|
||||
@@ -529,6 +735,7 @@ function ReviewTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
@@ -615,12 +822,14 @@ function CaseTab() {
|
||||
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: ['roster-list'],
|
||||
@@ -637,6 +846,11 @@ function CaseTab() {
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
// 自动保存到历史
|
||||
if (res.data?.result && !res.data.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 {
|
||||
@@ -644,6 +858,17 @@ function CaseTab() {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -684,7 +909,15 @@ function CaseTab() {
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="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"
|
||||
@@ -697,6 +930,7 @@ function CaseTab() {
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function Contracts() {
|
||||
mutationFn: (data: any) => api.post('/employees', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowAddModal(false)
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function Roster() {
|
||||
mutationFn: (data: any) => api.post('/termination/draft', {
|
||||
employeeId: data.employeeId,
|
||||
type: 'RESIGNATION',
|
||||
reason: 'NEGOTIATED',
|
||||
reason: 'RESIGNATION',
|
||||
terminationDate: data.terminationDate,
|
||||
resignationReason: data.resignationReason,
|
||||
remark: data.remark,
|
||||
@@ -792,8 +792,8 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
if (!profile) return <div className="text-center py-8 text-gray-400">员工不存在</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
<div className="flex items-center gap-3 shrink-0 pb-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -803,7 +803,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
@@ -821,6 +821,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-3">
|
||||
{tab === 'basic' && <BasicInfo profile={profile} />}
|
||||
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{tab === 'payslip' && <PayslipInfo payslips={profile.payslips} />}
|
||||
@@ -832,6 +833,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{tab === 'attachment' && <AttachmentInfo employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -903,6 +905,13 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.reverse()[0] || '未记录' }]
|
||||
: []),
|
||||
]
|
||||
const special = [
|
||||
{ label: '孕期', value: profile.isPregnant },
|
||||
@@ -2808,6 +2817,17 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const riskStyle: Record<string, string> = {
|
||||
DANGER: 'bg-red-50 border-red-300 text-red-700',
|
||||
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
|
||||
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
|
||||
}
|
||||
const riskIcon: Record<string, string> = {
|
||||
DANGER: '🔴',
|
||||
HIGH: '🟠',
|
||||
MEDIUM: '🟡',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
@@ -2831,14 +2851,41 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
<div className="text-gray-500">未签字</div>
|
||||
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
|
||||
</div>
|
||||
{data.summary.riskCount > 0 && (
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">风险项</div>
|
||||
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleExport}>导出证据链</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.risks && data.risks.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
风险提醒({data.risks.length}项)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.risks.map((r: any, i: number) => (
|
||||
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{riskIcon[r.level] || '⚠'}</span>
|
||||
<span className="font-medium text-xs">{r.title}</span>
|
||||
<span className="text-xs opacity-70">{r.category}</span>
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-90">{r.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.evidence.map((e: any, i: number) => (
|
||||
<Card key={i}>
|
||||
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
|
||||
{e.category}
|
||||
@@ -2849,6 +2896,7 @@ function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
<span className="text-xs text-gray-400">{e.date}</span>
|
||||
{e.acknowledged === true && <span className="text-xs text-safe">✓ 已签字</span>}
|
||||
{e.acknowledged === false && <span className="text-xs text-warning">⚠ 未签字</span>}
|
||||
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger">⚠ 高风险</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -97,12 +97,12 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (orgData?.data) {
|
||||
if (orgData) {
|
||||
setForm({
|
||||
name: orgData.data.name || '',
|
||||
contactName: orgData.data.contactName || '',
|
||||
contactPhone: orgData.data.contactPhone || '',
|
||||
payrollFrequency: orgData.data.payrollFrequency || 1,
|
||||
name: orgData.name || '',
|
||||
contactName: orgData.contactName || '',
|
||||
contactPhone: orgData.contactPhone || '',
|
||||
payrollFrequency: orgData.payrollFrequency || 1,
|
||||
})
|
||||
}
|
||||
}, [orgData])
|
||||
@@ -146,7 +146,7 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<any>(null)
|
||||
const users = usersData?.data || []
|
||||
const users = usersData || []
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.put(`/settings/users/${id}`, data),
|
||||
@@ -411,7 +411,7 @@ function ExportSettings() {
|
||||
|
||||
function PlanSettings({ orgData }: { orgData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = orgData?.data?.plan || 'FREE'
|
||||
const plan = orgData?.plan || 'FREE'
|
||||
|
||||
const { data: usageData } = useQuery<any>({
|
||||
queryKey: ['usage'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban } from 'lucide-react'
|
||||
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 jsPDF from 'jspdf'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -229,6 +229,9 @@ export default function Termination() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
|
||||
setStep(5)
|
||||
},
|
||||
})
|
||||
@@ -308,7 +311,9 @@ export default function Termination() {
|
||||
toast.success('解聘已执行')
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['evidence-chain'] })
|
||||
setView('list')
|
||||
resetWizard()
|
||||
},
|
||||
@@ -321,6 +326,8 @@ export default function Termination() {
|
||||
onSuccess: () => {
|
||||
toast.success('已撤销')
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setView('list')
|
||||
},
|
||||
onError: () => toast.error('撤销失败'),
|
||||
@@ -410,7 +417,7 @@ export default function Termination() {
|
||||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
||||
|
||||
const canProceed = () => {
|
||||
if (step === 0) return !!employeeId && !selectedEmployee?.hasTermination
|
||||
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination)
|
||||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||||
if (step === 2) return true
|
||||
if (step === 3) return true
|
||||
@@ -673,6 +680,21 @@ export default function Termination() {
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'DRAFT' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`确认执行「${item.employeeName}」的解聘手续?\n确认后员工状态将变更为离职,社保/公积金将停缴,此操作不可撤销。`)) {
|
||||
setDraftId(item.id)
|
||||
executeMutation.mutate()
|
||||
}
|
||||
}}
|
||||
className="p-1 text-safe hover:opacity-70"
|
||||
aria-label="确定"
|
||||
title="确定执行"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user