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:
freedakgmail
2026-07-25 00:33:51 +08:00
parent bb25e1731c
commit 169a0e1117
12 changed files with 828 additions and 70 deletions
+265 -31
View File
@@ -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 && (
+1
View File
@@ -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)
},
+53 -5
View File
@@ -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>
+7 -7
View File
@@ -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'],
+25 -3
View File
@@ -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