feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
import { useState, useRef, useEffect } 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 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' | 'knowledge'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
const QUICK_QUESTIONS = [
|
||||
'员工入职没签合同怎么办?',
|
||||
'加班费怎么算?',
|
||||
'辞退员工需要赔多少?',
|
||||
'试用期最长可以约定几个月?',
|
||||
]
|
||||
|
||||
export default function AIAssistant() {
|
||||
const [tab, setTab] = useState<Tab>('chat')
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [
|
||||
{ key: 'chat', label: '智能问答', icon: Bot },
|
||||
{ key: 'predict', label: '风险预测', icon: Sparkles },
|
||||
{ key: 'review', label: '合同审查', icon: FileSearch },
|
||||
{ key: 'case', label: '案例匹配', icon: Scale },
|
||||
{ key: 'knowledge', label: '知识库', icon: BookOpen },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xs font-medium">AI 合规顾问</h1>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tab === 'chat' && <ChatTab />}
|
||||
{tab === 'predict' && <PredictTab />}
|
||||
{tab === 'review' && <ReviewTab />}
|
||||
{tab === 'case' && <CaseTab />}
|
||||
{tab === 'knowledge' && <KnowledgeTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
|
||||
return
|
||||
}
|
||||
if (recording) {
|
||||
recognitionRef.current?.stop()
|
||||
setRecording(false)
|
||||
return
|
||||
}
|
||||
const recognition = new SpeechRecognition()
|
||||
recognition.lang = 'zh-CN'
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = false
|
||||
recognition.onresult = (event: any) => {
|
||||
const transcript = event.results[0]?.[0]?.transcript || ''
|
||||
setInput((prev) => prev + transcript)
|
||||
}
|
||||
recognition.onerror = () => setRecording(false)
|
||||
recognition.onend = () => setRecording(false)
|
||||
recognition.start()
|
||||
recognitionRef.current = recognition
|
||||
setRecording(true)
|
||||
}
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const content = text || input.trim()
|
||||
if (!content || loading) return
|
||||
|
||||
const newMessages = [...messages, { role: 'user' as const, content }]
|
||||
setMessages([...newMessages, { role: 'assistant', content: '' }])
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: newMessages }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null)
|
||||
throw new Error(errData?.error?.message || '请求失败')
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
let buffer = ''
|
||||
|
||||
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
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||
}
|
||||
} catch (err: any) {
|
||||
const isTimeout = err.name === 'AbortError'
|
||||
setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
||||
{/* 顶部操作栏 */}
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" />新对话</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" />历史会话</Button>
|
||||
{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'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 快捷问题 */}
|
||||
{messages.length <= 1 && (
|
||||
<div className="flex flex-wrap gap-2 pb-3">
|
||||
{QUICK_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => send(q)}
|
||||
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
placeholder="输入问题..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
|
||||
<Mic className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => send()} disabled={loading || !input.trim()}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 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 || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrediction()
|
||||
}, [])
|
||||
|
||||
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>
|
||||
</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" /> 分析中...
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewTab() {
|
||||
const [contractText, setContractText] = useState('')
|
||||
const [result, setResult] = useState<any>(null)
|
||||
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
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await api.post('/ai/review', { contractText }) as any
|
||||
setResult(res.data)
|
||||
} catch (err: any) {
|
||||
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
toast.success('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
|
||||
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
|
||||
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">合同审查</h2>
|
||||
</div>
|
||||
<Label>粘贴合同条款文本</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
|
||||
placeholder="粘贴劳动合同文本..."
|
||||
value={contractText}
|
||||
onChange={(e) => setContractText(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />审查中...</> : '开始审查'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">审查结果</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
{result.error ? (
|
||||
<div className="text-xs text-danger">{result.error}</div>
|
||||
) : result.structured ? (
|
||||
<div className="space-y-3">
|
||||
{/* 合规评分 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">合规评分</span>
|
||||
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
|
||||
{result.structured.score}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 风险项列表 */}
|
||||
{result.structured.riskItems.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">风险项({result.structured.riskItems.length})</h4>
|
||||
{result.structured.riskItems.map((item: any, i: number) => {
|
||||
const cfg = levelConfig[item.level] || levelConfig.YELLOW
|
||||
return (
|
||||
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
|
||||
<div className="text-xs text-gray-500">建议:{item.suggestion}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 总体建议 */}
|
||||
{result.structured.summary && (
|
||||
<div className="border-t pt-2">
|
||||
<h4 className="text-xs font-medium mb-1">总体建议</h4>
|
||||
<p className="text-xs text-gray-600">{result.structured.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原始文本可展开 */}
|
||||
<details className="border-t pt-2">
|
||||
<summary className="text-xs text-gray-400 cursor-pointer">查看原始文本</summary>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(result)}</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 [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 { 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
|
||||
setLoading(true)
|
||||
setResult('')
|
||||
try {
|
||||
const res = await api.post('/ai/match-case', { scenario }) as any
|
||||
setResult(res.data.result)
|
||||
} catch (err: any) {
|
||||
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!saveEmployeeId || !result) return
|
||||
try {
|
||||
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
|
||||
setShowSaveModal(false)
|
||||
setSaveEmployeeId('')
|
||||
toast.success('已保存到员工档案')
|
||||
} catch (err: any) {
|
||||
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTodo = async () => {
|
||||
if (!todoEmployeeId || !todoTitle) return
|
||||
setCreatingTodo(true)
|
||||
try {
|
||||
await api.post('/ai/case-to-todo', {
|
||||
employeeId: todoEmployeeId,
|
||||
title: todoTitle,
|
||||
description: result.slice(0, 500),
|
||||
level: todoLevel,
|
||||
type: todoType,
|
||||
})
|
||||
setShowTodoModal(false)
|
||||
setTodoEmployeeId('')
|
||||
setTodoTitle('')
|
||||
toast.success('已创建待办风险项')
|
||||
} catch (err: any) {
|
||||
toast.error('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
|
||||
} finally {
|
||||
setCreatingTodo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">案例匹配</h2>
|
||||
</div>
|
||||
<Label>描述你的争议情形</Label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
|
||||
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
|
||||
value={scenario}
|
||||
onChange={(e) => setScenario(e.target.value)}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />分析中...</> : '分析'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{result && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium">分析结果</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" />转待办</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" />保存到员工档案</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showSaveModal && (
|
||||
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">保存到员工档案</h3>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showTodoModal && (
|
||||
<Modal open onClose={() => setShowTodoModal(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">转为待办风险项</h3>
|
||||
<div>
|
||||
<Label>选择员工</Label>
|
||||
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
|
||||
<option value="">选择员工</option>
|
||||
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}({e.department})</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>待办标题</Label>
|
||||
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>风险等级</Label>
|
||||
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
|
||||
<option value="HIGH">高</option>
|
||||
<option value="MEDIUM">中</option>
|
||||
<option value="LOW">低</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>风险类型</Label>
|
||||
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
|
||||
<option value="CONTRACT">合同</option>
|
||||
<option value="SALARY">薪酬</option>
|
||||
<option value="TERMINATION">解聘</option>
|
||||
<option value="MONTHLY">月度</option>
|
||||
<option value="ONBOARDING">入职</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">分析结果将作为待办描述自动填入</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
|
||||
{creatingTodo ? '创建中...' : '创建待办'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KnowledgeTab() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const { data: knowledgeList, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['rag-knowledge'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/ai/rag/list') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: typeof newItem) => {
|
||||
return await api.post('/ai/rag/add', data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
|
||||
setShowAdd(false)
|
||||
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/ai/rag/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const seedMutation = useMutation({
|
||||
mutationFn: () => api.post('/ai/rag/seed'),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
|
||||
})
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newItem.title || !newItem.content) return
|
||||
setAdding(true)
|
||||
try {
|
||||
await addMutation.mutateAsync(newItem)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">共 {knowledgeList?.length || 0} 条知识</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
|
||||
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加知识
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !knowledgeList || knowledgeList.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">知识库为空,请点击「初始化知识库」</div></Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{knowledgeList.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium">{item.title}</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
|
||||
<div className="text-xs text-gray-400 mt-1">来源:{item.source}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
className="text-gray-400 hover:text-danger flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<Modal open onClose={() => setShowAdd(false)}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">添加知识条目</h3>
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>内容</Label>
|
||||
<textarea
|
||||
value={newItem.content}
|
||||
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
|
||||
placeholder="法律条文或知识内容"
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>来源</Label>
|
||||
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>分类</Label>
|
||||
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
|
||||
<option value="其他">其他</option>
|
||||
<option value="法律法规">法律法规</option>
|
||||
<option value="司法解释">司法解释</option>
|
||||
<option value="地方性法规">地方性法规</option>
|
||||
<option value="案例分析">案例分析</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
|
||||
{adding ? '添加中...' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Calculator, Info, AlertCircle } 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'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
interface EmployeeOption {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
hireDate: string
|
||||
monthlySalary: number
|
||||
status: string
|
||||
contracts?: any[]
|
||||
}
|
||||
|
||||
function useEmployees() {
|
||||
return useQuery<EmployeeOption[]>({
|
||||
queryKey: ['roster-for-compensation'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function EmployeeSelector({ employees, selectedId, onSelect }: {
|
||||
employees?: EmployeeOption[]
|
||||
selectedId: string
|
||||
onSelect: (emp: EmployeeOption | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label>选择员工(自动带出入职日期和工资)</Label>
|
||||
<Select value={selectedId} onChange={(e) => {
|
||||
const emp = employees?.find((x) => x.id === e.target.value)
|
||||
onSelect(emp || null)
|
||||
}}>
|
||||
<option value="">-- 手动输入 --</option>
|
||||
{employees?.map((emp) => (
|
||||
<option key={emp.id} value={emp.id}>
|
||||
{emp.name}({emp.department})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Compensation() {
|
||||
const [tab, setTab] = useState<'severance' | 'double'>('severance')
|
||||
|
||||
const tabs: { key: typeof tab; label: string }[] = [
|
||||
{ key: 'severance', label: '经济补偿金' },
|
||||
{ key: 'double', label: '双倍工资' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">补偿计算</h1>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'severance' && <SeveranceCalculator />}
|
||||
{tab === 'double' && <DoubleSalaryCalculator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SeveranceCalculator() {
|
||||
const { data: employees } = useEmployees()
|
||||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||||
const [hireDate, setHireDate] = useState('')
|
||||
const [leaveDate, setLeaveDate] = useState('')
|
||||
const [avgWage, setAvgWage] = useState(8000)
|
||||
const [reason, setReason] = useState('negotiated')
|
||||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
|
||||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||||
setSelectedEmpId(emp?.id || '')
|
||||
if (emp) {
|
||||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||||
setAvgWage(emp.monthlySalary || 8000)
|
||||
}
|
||||
}
|
||||
|
||||
const reasonMap: Record<string, { label: string; multiplier: number; extra: string; illegal: boolean }> = {
|
||||
negotiated: { label: '协商一致解除', multiplier: 1, extra: '', illegal: false },
|
||||
fault: { label: '员工过错解除', multiplier: 0, extra: '员工过错解除,无需支付经济补偿金', illegal: false },
|
||||
nonfault: { label: '非过错解除', multiplier: 1, extra: '额外支付1个月代通知金', illegal: false },
|
||||
layoff: { label: '经济性裁员', multiplier: 1, extra: '', illegal: false },
|
||||
expired: { label: '合同到期不续签', multiplier: 1, extra: '用人单位不续签或降低条件续签', illegal: false },
|
||||
illegal: { label: '违法解除', multiplier: 2, extra: '违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)', illegal: true },
|
||||
}
|
||||
|
||||
const handleCalculate = () => {
|
||||
if (!hireDate || !leaveDate) return
|
||||
const hire = new Date(hireDate)
|
||||
const leave = new Date(leaveDate)
|
||||
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
let wage = avgWage
|
||||
let capped = false
|
||||
if (socialAvgWage > 0 && avgWage > socialAvgWage * 3) {
|
||||
wage = socialAvgWage * 3
|
||||
compMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
const r = reasonMap[reason]
|
||||
const basePay = wage * compMonths
|
||||
let totalPay = basePay * r.multiplier
|
||||
let noticePay = 0
|
||||
if (reason === 'nonfault') {
|
||||
noticePay = wage
|
||||
totalPay += noticePay
|
||||
}
|
||||
|
||||
setResult({ years, remainingMonths, compMonths, wage, totalPay, basePay, totalMonths, capped, reason: r.label, reasonNote: r.extra, noticePay, noComp: r.multiplier === 0, isIllegal: r.illegal })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>入职日期</Label>
|
||||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职日期</Label>
|
||||
<Input type="date" value={leaveDate} onChange={(e) => setLeaveDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月平均工资(元)</Label>
|
||||
<Input type="number" value={avgWage} onChange={(e) => setAvgWage(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职原因</Label>
|
||||
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
|
||||
<option value="negotiated">协商一致解除</option>
|
||||
<option value="fault">员工过错解除</option>
|
||||
<option value="nonfault">非过错解除(额外1个月代通知金)</option>
|
||||
<option value="layoff">经济性裁员</option>
|
||||
<option value="expired">合同到期不续签</option>
|
||||
<option value="illegal">违法解除(赔偿金×2)</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>当地社平工资(选填)</Label>
|
||||
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
|
||||
</div>
|
||||
<Button onClick={handleCalculate} disabled={!hireDate || !leaveDate} className="w-full">
|
||||
<Calculator className="w-4 h-4 mr-1" /> 计算
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
<div className="text-xs text-gray-500">工作年限:<span className="text-gray-900">{result.years}年{result.remainingMonths}个月</span></div>
|
||||
{result.noComp ? (
|
||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
||||
{result.reasonNote}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-gray-500">补偿月数:<span className="text-gray-900">{result.compMonths}个月</span></div>
|
||||
{result.capped && (
|
||||
<div className="text-xs text-warning">⚠️ 工资超过社平3倍,已按三倍封顶且最多补偿12个月</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">计算基数:<span className="text-gray-900">¥{fmt(result.wage)}/月</span></div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
|
||||
<span className="font-medium">¥{fmt(result.basePay)}</span>
|
||||
</div>
|
||||
{result.isIllegal ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-danger">违法解除赔偿金(×2)</span>
|
||||
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)} × 2)</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{result.reason}</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">({result.compMonths}个月 × ¥{fmt(result.wage)})
|
||||
{result.noticePay > 0 && <span className="block">含代通知金 ¥{fmt(result.noticePay)}</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{result.reasonNote && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{result.reasonNote}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>满1年补1个月,满6个月不满1年按1年算,不满6个月补半个月</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-xs">填写信息后点击「计算」按钮</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoubleSalaryCalculator() {
|
||||
const { data: employees } = useEmployees()
|
||||
const [selectedEmpId, setSelectedEmpId] = useState('')
|
||||
const [monthlyWage, setMonthlyWage] = useState(8000)
|
||||
const [hireDate, setHireDate] = useState('')
|
||||
const [hasContract, setHasContract] = useState(false)
|
||||
const [contractDate, setContractDate] = useState('')
|
||||
|
||||
const handleSelectEmp = (emp: EmployeeOption | null) => {
|
||||
setSelectedEmpId(emp?.id || '')
|
||||
if (emp) {
|
||||
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
|
||||
setMonthlyWage(emp.monthlySalary || 8000)
|
||||
const latestContract = emp.contracts?.find((c: any) => c.signDate)
|
||||
if (latestContract) {
|
||||
setHasContract(true)
|
||||
setContractDate(latestContract.signDate?.toString().slice(0, 10) || '')
|
||||
} else {
|
||||
setHasContract(false)
|
||||
setContractDate('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = useMemo(() => {
|
||||
if (!hireDate) return null
|
||||
const hire = new Date(hireDate)
|
||||
const startDate = new Date(hire)
|
||||
startDate.setMonth(startDate.getMonth() + 1)
|
||||
startDate.setDate(startDate.getDate() + 1)
|
||||
|
||||
let endDate = new Date(hire)
|
||||
endDate.setFullYear(endDate.getFullYear() + 1)
|
||||
|
||||
if (hasContract && contractDate) {
|
||||
const contract = new Date(contractDate)
|
||||
const daysDiff = Math.floor((contract.getTime() - hire.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (daysDiff > 30) {
|
||||
endDate = contract
|
||||
}
|
||||
}
|
||||
|
||||
const months = Math.min(
|
||||
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
|
||||
11,
|
||||
)
|
||||
const totalPay = monthlyWage * Math.max(months, 0)
|
||||
|
||||
return { startDate, endDate, months: Math.max(months, 0), totalPay }
|
||||
}, [monthlyWage, hireDate, hasContract, contractDate])
|
||||
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
<Label>月工资(元)</Label>
|
||||
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>入职日期</Label>
|
||||
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同签订情况</Label>
|
||||
<Select value={hasContract ? 'yes' : 'no'} onChange={(e) => setHasContract(e.target.value === 'yes')}>
|
||||
<option value="no">未签订</option>
|
||||
<option value="yes">已签订</option>
|
||||
</Select>
|
||||
</div>
|
||||
{hasContract && (
|
||||
<div>
|
||||
<Label>合同签订日期</Label>
|
||||
<Input type="date" value={contractDate} onChange={(e) => setContractDate(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
<div className="text-xs text-gray-500">合同签订:<span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
|
||||
<div className="text-xs text-gray-500">双倍工资起算:<span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="text-xs text-gray-500">双倍工资截止:<span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">需赔</span>
|
||||
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">({result.months}个月 × ¥{fmt(monthlyWage)})</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>法律规定:入职1个月没签合同,从第2个月起要付双倍工资,最多11个月</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-xs">请填写入职日期</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Paperclip, Trash2, 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'
|
||||
|
||||
interface EmployeeItem {
|
||||
id: string
|
||||
name: string
|
||||
department: string
|
||||
hireDate: string
|
||||
status: string
|
||||
contractStatus: string
|
||||
contractStatusText: string
|
||||
riskLevel: 'high' | 'medium' | 'low' | 'safe'
|
||||
isPregnant: boolean
|
||||
isInMedicalPeriod: boolean
|
||||
isWorkInjured: boolean
|
||||
}
|
||||
|
||||
interface EmployeeListResponse {
|
||||
items: EmployeeItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export default function Contracts() {
|
||||
const queryClient = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery<EmployeeListResponse>({
|
||||
queryKey: ['employees', search, page],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/employees', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['employees'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowAddModal(false)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">合同管理</h1>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索员工姓名或手机号"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工列表 */}
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无员工"
|
||||
description="点击「添加员工」开始管理合同"
|
||||
actionLabel="添加员工"
|
||||
onAction={() => setShowAddModal(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
<th className="py-2 px-3 font-medium">部门</th>
|
||||
<th className="py-2 px-3 font-medium">入职日期</th>
|
||||
<th className="py-2 px-3 font-medium">合同状态</th>
|
||||
<th className="py-2 px-3 font-medium">特殊状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((emp) => (
|
||||
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedEmpId(emp.id)}>
|
||||
<td className="py-3 px-3 font-medium">{emp.name}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
|
||||
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
|
||||
<td className="py-3 px-3">
|
||||
{(() => {
|
||||
const tagStyles: Record<string, string> = {
|
||||
expired: 'bg-red-50 text-danger',
|
||||
unsigned_over_year: 'bg-red-50 text-danger',
|
||||
unsigned_over_30: 'bg-red-50 text-danger',
|
||||
unsigned: 'bg-yellow-50 text-yellow-700',
|
||||
expiring: 'bg-yellow-50 text-yellow-700',
|
||||
active: 'bg-green-50 text-safe',
|
||||
unfixed: 'bg-blue-50 text-blue-700',
|
||||
}
|
||||
const style = tagStyles[emp.contractStatus] || 'bg-gray-100 text-gray-600'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{emp.contractStatusText}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex gap-1">
|
||||
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600">孕期</span>}
|
||||
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600">医疗期</span>}
|
||||
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600">工伤</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>上一页</Button>
|
||||
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === data.totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 添加员工 Modal */}
|
||||
<AddEmployeeModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSubmit={(data) => addMutation.mutate(data)}
|
||||
loading={addMutation.isPending}
|
||||
error={addMutation.error as any}
|
||||
/>
|
||||
|
||||
{/* 员工详情抽屉 */}
|
||||
{selectedEmpId && (
|
||||
<EmployeeDetailDrawer employeeId={selectedEmpId} onClose={() => setSelectedEmpId(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
department: '',
|
||||
hireDate: '',
|
||||
monthlySalary: '',
|
||||
gender: '男' as '男' | '女',
|
||||
phone: '',
|
||||
isPregnant: false,
|
||||
isInMedicalPeriod: false,
|
||||
isWorkInjured: false,
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
contractYears: 3,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
name: form.name,
|
||||
department: form.department,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary,
|
||||
gender: form.gender,
|
||||
phone: form.phone || undefined,
|
||||
isPregnant: form.isPregnant,
|
||||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||||
isWorkInjured: form.isWorkInjured,
|
||||
}
|
||||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||||
data.contract = {
|
||||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||||
contractType: form.contractType,
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="添加员工">
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{error.response?.data?.error?.message || '操作失败'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>部门 *</Label>
|
||||
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>入职日期 *</Label>
|
||||
<Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月工资 *</Label>
|
||||
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>性别</Label>
|
||||
<Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 特殊状态 */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||||
孕期/哺乳期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||||
医疗期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||||
工伤
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 合同信息 */}
|
||||
<div className="border-t pt-3">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
|
||||
<option value="FIXED">固定期限</option>
|
||||
<option value="UNFIXED">无固定期限</option>
|
||||
<option value="UNSIGNED">未签合同</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{form.contractType !== 'UNSIGNED' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>签订日期</Label>
|
||||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同开始日期 *</Label>
|
||||
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期(月)</Label>
|
||||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期工资</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.department || !form.hireDate || !form.monthlySalary}>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
|
||||
|
||||
const { data: employee } = useQuery<any>({
|
||||
queryKey: ['employee-detail', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/employees/${employeeId}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: attachments } = useQuery<any[]>({
|
||||
queryKey: ['employee-attachments', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/attachments/${employeeId}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const addAttachmentMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/attachments', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
|
||||
})
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
|
||||
})
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const fileUrl = event.target?.result as string
|
||||
addAttachmentMutation.mutate({
|
||||
employeeId,
|
||||
fileName: file.name,
|
||||
fileType,
|
||||
fileUrl,
|
||||
fileSize: file.size,
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const fileTypeLabels: Record<string, string> = {
|
||||
ID_CARD: '身份证',
|
||||
BANK_CARD: '银行卡',
|
||||
CONTRACT_SCAN: '合同扫描件',
|
||||
EDUCATION: '学历证书',
|
||||
OTHER: '其他',
|
||||
}
|
||||
|
||||
const emp = employee?.data || employee
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
<button className="fixed inset-0 bg-black/40 cursor-default" onClick={onClose} aria-label="关闭" />
|
||||
<div className="relative w-full max-w-2xl bg-white h-full overflow-y-auto shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-medium text-gray-900">员工详情</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{emp && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{emp.name}</h2>
|
||||
<span className="text-xs text-gray-500">{emp.department}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span className="text-gray-400">入职日期:</span>{emp.hireDate?.slice(0, 10)}</div>
|
||||
<div><span className="text-gray-400">性别:</span>{emp.gender || '-'}</div>
|
||||
<div><span className="text-gray-400">手机:</span>{emp.phone || '-'}</div>
|
||||
<div><span className="text-gray-400">状态:</span>{emp.status === 'ACTIVE' ? '在职' : '离职'}</div>
|
||||
</div>
|
||||
{(emp.isPregnant || emp.isInMedicalPeriod || emp.isWorkInjured) && (
|
||||
<div className="flex gap-1">
|
||||
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600">孕期</span>}
|
||||
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600">医疗期</span>}
|
||||
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600">工伤</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{emp.contracts && emp.contracts.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-xs mb-2">合同信息</h3>
|
||||
<div className="space-y-2 text-xs">
|
||||
{emp.contracts.map((c: any) => (
|
||||
<div key={c.id} className="bg-gray-50 rounded p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'
|
||||
const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${typeStyle}`}>{typeLabel}</span>
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-medium text-xs flex items-center gap-1">
|
||||
<Paperclip className="w-4 h-4" /> 附件管理
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
|
||||
<option value="ID_CARD">身份证</option>
|
||||
<option value="BANK_CARD">银行卡</option>
|
||||
<option value="CONTRACT_SCAN">合同扫描件</option>
|
||||
<option value="EDUCATION">学历证书</option>
|
||||
<option value="OTHER">其他</option>
|
||||
</Select>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={addAttachmentMutation.isPending}
|
||||
>
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{attachments && attachments.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((att: any) => (
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{att.fileName}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteAttachmentMutation.mutate(att.id)}
|
||||
className="text-gray-400 hover:text-danger shrink-0 ml-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-xs text-center py-4">暂无附件</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
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'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import type { DashboardData } from '../types'
|
||||
|
||||
function fmt(n: number) {
|
||||
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; bg: string }> = {
|
||||
CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
|
||||
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
ONBOARDING: { icon: UserPlus, color: 'text-cyan-600', bg: 'bg-cyan-50' },
|
||||
}
|
||||
|
||||
function TodoIcon({ type }: { type: string; level: string }) {
|
||||
const config = TODO_ICON_CONFIG[type] || TODO_ICON_CONFIG.MONTHLY
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [todoPage, setTodoPage] = useState(1)
|
||||
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 () => {
|
||||
const res = await api.get('/dashboard') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: expiringContracts } = useQuery<any>({
|
||||
queryKey: ['expiring-contracts'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/contracts/expiring') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
})
|
||||
|
||||
const ignoreMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/ignore`),
|
||||
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
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const stats = [
|
||||
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
|
||||
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
|
||||
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
|
||||
{ label: '月加班费', value: fmt(data.stats.monthlyOvertimePay), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payroll = data.payrollSummary
|
||||
const activities = data.monthlyActivities
|
||||
|
||||
const activityItems = [
|
||||
{ label: '新签合同', value: activities?.newContracts ?? 0, icon: FileText, color: 'text-primary' },
|
||||
{ label: '解聘人数', value: activities?.terminations ?? 0, icon: Users, color: 'text-danger' },
|
||||
{ label: '违纪处理', value: activities?.disciplinaryActions ?? 0, icon: AlertTriangle, color: 'text-warning' },
|
||||
{ label: '考勤记录', value: activities?.attendanceRecords ?? 0, icon: Calendar, color: 'text-gray-600' },
|
||||
{ label: '加班时长', value: `${activities?.overtimeHours ?? 0}h`, icon: TrendingUp, color: 'text-safe' },
|
||||
{ label: '加班费', value: fmt(activities?.overtimePay ?? 0), icon: DollarSign, color: 'text-safe' },
|
||||
]
|
||||
|
||||
const payrollItems = [
|
||||
{ label: '基本工资', value: payroll?.baseSalary ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '加班费', value: payroll?.overtimePay ?? 0, icon: TrendingUp, color: 'text-gray-700' },
|
||||
{ label: '津贴补贴', value: payroll?.allowance ?? 0, icon: Wallet, color: 'text-gray-700' },
|
||||
{ label: '扣款', value: -(payroll?.deduction ?? 0), icon: Wallet, color: 'text-danger' },
|
||||
]
|
||||
|
||||
const deductionItems = [
|
||||
{ label: '个人社保', value: -(payroll?.socialEmp ?? 0) },
|
||||
{ label: '个人公积金', value: -(payroll?.housingEmp ?? 0) },
|
||||
{ label: '个人所得税', value: -(payroll?.estimatedTax ?? 0) },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
|
||||
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
|
||||
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
{tab.badge > 0 && (
|
||||
<span className={`ml-1 px-1.5 py-0.5 rounded-full text-xs ${activeTab === tab.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 概览 Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<Card key={stat.label} className="flex items-center gap-2.5">
|
||||
<Icon className={`w-6 h-6 ${stat.color}`} />
|
||||
<div>
|
||||
<div className="text-base font-bold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</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-500"> 等{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">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
||||
<span className="text-xs text-gray-500">{activities?.month}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||
{activityItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
|
||||
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
|
||||
<div className="text-xs font-bold">{item.value}</div>
|
||||
<div className="text-xs text-gray-500">{item.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 风险分布 */}
|
||||
<Card>
|
||||
<h2 className="font-medium mb-3">风险分布</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-32 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
||||
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
||||
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
||||
].filter(d => d.value > 0)}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={30}
|
||||
outerRadius={55}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{[
|
||||
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
|
||||
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
|
||||
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
|
||||
].filter(d => d.value > 0).map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => `${v} 项`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
|
||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
|
||||
合同风险
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">{data.riskDistribution.contract}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
|
||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-warning" />
|
||||
薪资风险
|
||||
</span>
|
||||
<span className="text-sm font-bold text-warning">{data.riskDistribution.salary}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
|
||||
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-danger" />
|
||||
解聘风险
|
||||
</span>
|
||||
<span className="text-sm font-bold text-danger">{data.riskDistribution.termination}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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-500 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-500">{r.employeeName}</div>}
|
||||
</div>
|
||||
<ArrowRight className="w-3 h-3 text-gray-500" />
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-2">暂无高风险项</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 薪税 Tab */}
|
||||
{activeTab === 'payroll' && (
|
||||
<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>
|
||||
<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 ? (
|
||||
<div className="space-y-3">
|
||||
{/* 工资构成 */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1.5">工资构成</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{payrollItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className={`w-4 h-4 ${item.color}`} />
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 应发合计 */}
|
||||
<div className="flex items-center justify-between border-t border-b py-2">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 扣减项 */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1.5">扣减项</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{deductionItems.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
|
||||
<span className="text-xs text-gray-500">{item.label}</span>
|
||||
<span className="text-xs font-medium text-danger">{fmt(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工实发 */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" />员工实发工资</span>
|
||||
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
|
||||
</div>
|
||||
|
||||
{/* 企业成本 */}
|
||||
<div className="border-t pt-2 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">企业用工成本</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业社保</span>
|
||||
<span className="text-xs font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" />企业公积金</span>
|
||||
<span className="text-xs font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" />工资总额</span>
|
||||
<span className="text-xs font-medium text-green-700">{fmt(payroll.totalPay)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{payroll.severancePay > 0 && (
|
||||
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" />经济补偿金</span>
|
||||
<span className="text-xs font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" />企业总成本</span>
|
||||
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工资条确认状态 */}
|
||||
<div className="flex items-center gap-3 text-xs border-t pt-2">
|
||||
<span className="text-gray-500">工资条确认:</span>
|
||||
<span className="text-safe">已确认 {payroll.confirmedPayslips}</span>
|
||||
<span className="text-warning">未确认 {payroll.unconfirmedPayslips}</span>
|
||||
<span className="text-gray-500">共 {payroll.payslipCount} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 风险提醒 Tab */}
|
||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||
<div className="space-y-3">
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<span className="text-xs text-gray-500">{filteredTodos.length} 项</span>
|
||||
</div>
|
||||
|
||||
{filteredTodos.length === 0 ? (
|
||||
<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-500">已选 {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) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<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-500 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)}
|
||||
disabled={resolveMutation.isPending}
|
||||
className="p-1.5 rounded hover:bg-safe/10 text-safe"
|
||||
title="标记完成"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => ignoreMutation.mutate(todo.id)}
|
||||
disabled={ignoreMutation.isPending}
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500"
|
||||
title="忽略"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 已办事项 */}
|
||||
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
||||
<span className="text-xs text-gray-500">{data.resolvedTodos.length} 项</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{data.resolvedTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
|
||||
>
|
||||
<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-600 line-through">{todo.title}</span>
|
||||
<span className="text-xs text-gray-500">{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<span className="text-xs text-gray-500">
|
||||
{todo.resolvedAt ? new Date(todo.resolvedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,754 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export 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)
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
const [adjustData, setAdjustData] = useState<any>(null)
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
pensionOrg: 16, pensionEmp: 8,
|
||||
medicalOrg: 9.8, medicalEmp: 2,
|
||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
})
|
||||
const [newHousingVersion, setNewHousingVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
housingOrg: 12, housingEmp: 12,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
})
|
||||
|
||||
// 获取城市列表
|
||||
const { data: cities = [] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/cities') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: config, isLoading: configLoading } = 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, isLoading: housingLoading } = useQuery<any>({
|
||||
queryKey: ['housing-config', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/housing-config', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['social-config-versions', city],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/versions', { params: { city } }) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showVersions && tab === 'social',
|
||||
})
|
||||
|
||||
const { data: housingVersions } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-versions'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/housing-config/versions') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showVersions && tab === 'housing',
|
||||
})
|
||||
|
||||
const { data: monthlyChanges } = useQuery<any>({
|
||||
queryKey: ['monthly-changes', monthlyMonth],
|
||||
queryFn: async () => {
|
||||
const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([
|
||||
api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any,
|
||||
api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any,
|
||||
api.get('/social/active-declaration', { params: { month: monthlyMonth } }) as any,
|
||||
api.get('/social/housing/active-declaration', { params: { month: monthlyMonth } }) as any,
|
||||
])
|
||||
return {
|
||||
social: socialRes.data,
|
||||
housing: housingRes.data,
|
||||
socialActive: socialActiveRes.data,
|
||||
housingActive: housingActiveRes.data,
|
||||
}
|
||||
},
|
||||
enabled: tab === 'monthly',
|
||||
})
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/social/calculate', { base }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/social/housing-calculate', { base }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const createVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/social/config/versions', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
})
|
||||
|
||||
const createHousingVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/social/housing-config/versions', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('公积金新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
})
|
||||
|
||||
const previewAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const previewHousingAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.get(`/social/housing-config/${housingConfig?.id}/adjust-preview`) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const applyAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
api.post(`/social/config/${config?.id}/adjust-apply`, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const applyHousingAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
api.post(`/social/housing-config/${housingConfig?.id}/adjust-apply`, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
|
||||
toast.success('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`, { city }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
|
||||
toast.success('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||
if (!data?.items?.length) return
|
||||
const headers = type === 'social'
|
||||
? ['姓名', '部门', '社保基数', '开始年月', '截止年月', '变更类型']
|
||||
: ['姓名', '部门', '公积金基数', '开始年月', '截止年月', '变更类型']
|
||||
const rows = data.items.map((i: any) => [
|
||||
i.name, i.department, i.base, i.startMonth, i.endMonth || '', i.changeType
|
||||
])
|
||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${type === 'social' ? '社保' : '公积金'}_${data.month || monthlyMonth}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const isHousing = tab === 'housing'
|
||||
const activeConfig = isHousing ? housingConfig : config
|
||||
const activeVersions = isHousing ? housingVersions : versions
|
||||
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
|
||||
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
|
||||
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
|
||||
const activeNewVersion = isHousing ? newHousingVersion : newVersion
|
||||
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xs font-medium">社保公积金</h1>
|
||||
<div className="flex gap-2">
|
||||
{tab !== 'monthly' && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showVersions ? '收起历史' : '版本历史'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建版本
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['social', 'housing', 'monthly'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
|
||||
>
|
||||
{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 ========== */}
|
||||
{tab !== 'monthly' && (
|
||||
(isHousing ? housingLoading : configLoading) ? (
|
||||
<Card><div className="text-center py-8 text-gray-500">加载中...</div></Card>
|
||||
) : activeConfig ? (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">当前生效</span>
|
||||
<span className="text-xs text-gray-500">生效月份:{activeConfig.effectiveFrom}</span>
|
||||
<span className="text-xs text-gray-500">· {activeConfig.city}</span>
|
||||
{activeConfig.adjustmentDone && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已调整员工基数</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeConfig.adjustmentDone && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => activePreviewMut.mutate()}
|
||||
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(企业)</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(个人)</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医疗(企业/个人)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">工伤(企业)</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card><div className="text-center py-8 text-gray-500">该城市暂无{isHousing ? '公积金' : '社保'}配置,请点击「新建版本」创建</div></Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 调整预览 */}
|
||||
{tab !== 'monthly' && showAdjust && adjustData && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />员工{isHousing ? '公积金' : '社保'}基数调整
|
||||
</h3>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。
|
||||
建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
<Check className="w-3.5 h-3.5 mr-1" />全部采用建议值
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
全部保持原基数
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">共 {adjustData.total} 名员工</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-right">上年月均</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(当前)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(建议)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(新)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adjustData.items.map((item: any) => {
|
||||
const edit = editItems[item.employeeId]
|
||||
const newBase = edit ?? item.suggestedBase
|
||||
const changed = newBase !== item.oldBase
|
||||
return (
|
||||
<tr key={item.employeeId} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{item.department}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{editingId === item.employeeId ? (
|
||||
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
|
||||
onBlur={() => setEditingId(null)}
|
||||
autoFocus />
|
||||
) : (
|
||||
<span className="cursor-text inline-block !w-28 text-right"
|
||||
onClick={() => setEditingId(item.employeeId)}>
|
||||
¥{fmt(newBase)}
|
||||
</span>
|
||||
)}
|
||||
{changed && <span className="text-warning ml-1">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
|
||||
activeApplyMut.mutate({ items })
|
||||
}} disabled={activeApplyMut.isPending}>
|
||||
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 版本历史 */}
|
||||
{tab !== 'monthly' && showVersions && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}版本历史</h3>
|
||||
{!activeVersions || activeVersions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-xs">暂无版本记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 text-left">生效月份</th>
|
||||
<th className="py-2 text-left">失效月份</th>
|
||||
<th className="py-2 text-left">城市</th>
|
||||
<th className="py-2 text-right">基数下限</th>
|
||||
<th className="py-2 text-right">基数上限</th>
|
||||
{isHousing ? (
|
||||
<th className="py-2 text-right">公积金%</th>
|
||||
) : (
|
||||
<>
|
||||
<th className="py-2 text-right">养老%</th>
|
||||
<th className="py-2 text-right">医疗%</th>
|
||||
</>
|
||||
)}
|
||||
<th className="py-2 text-center">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeVersions.map((v: any) => (
|
||||
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{v.effectiveFrom}</td>
|
||||
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
|
||||
<td className="py-2">{v.city}</td>
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
|
||||
{isHousing ? (
|
||||
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
|
||||
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-center">
|
||||
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe">当前</span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400">历史</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建版本 */}
|
||||
{tab !== 'monthly' && showNewVersion && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />新建{isHousing ? '公积金' : '社保'}配置版本</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
||||
<div><Label>城市</Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>公积金(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<div><Label>养老(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>养老(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>工伤(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>生育(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
|
||||
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowNewVersion(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 试算工具 */}
|
||||
{tab !== 'monthly' && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="text-xs font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{activeConfig && (
|
||||
<div className="text-xs text-gray-400">
|
||||
当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
{(() => {
|
||||
const r = isHousing ? housingResult : result
|
||||
if (!r) return <div className="text-gray-400 text-xs">点击「开始计算」查看结果</div>
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
<th className="py-1.5 text-right">企业缴纳</th>
|
||||
<th className="py-1.5 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 月度办理 Tab ========== */}
|
||||
{tab === 'monthly' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-medium">月度办理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="month" value={monthlyMonth} onChange={(e) => setMonthlyMonth(e.target.value)} className="!w-32" />
|
||||
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('social', monthlyChanges.social)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出社保
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('housing', monthlyChanges.housing)}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />导出公积金
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md mb-3">
|
||||
展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。
|
||||
</div>
|
||||
{(() => {
|
||||
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-xs">加载中...</div>
|
||||
const sAdd = monthlyChanges.social?.additions || []
|
||||
const sSub = monthlyChanges.social?.subtractions || []
|
||||
const sNormal = monthlyChanges.socialActive?.items || []
|
||||
const hAdd = monthlyChanges.housing?.additions || []
|
||||
const hSub = monthlyChanges.housing?.subtractions || []
|
||||
const hNormal = monthlyChanges.housingActive?.items || []
|
||||
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) {
|
||||
return <div className="text-center py-4 text-gray-400 text-xs">{monthlyMonth} 无办理记录</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 社保 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium mb-2">社保</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-left">开始年月</th>
|
||||
<th className="py-2 text-left">截止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sAdd.map((i: any) => (
|
||||
<tr key={`sa-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe">新增</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
</tr>
|
||||
))}
|
||||
{sSub.map((i: any) => (
|
||||
<tr key={`ss-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger">减少</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
<td className="py-1.5">{i.endMonth}</td>
|
||||
</tr>
|
||||
))}
|
||||
{sNormal.map((i: any) => (
|
||||
<tr key={`sn-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500">正常</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/* 公积金 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium mb-2">公积金</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-left">类型</th>
|
||||
<th className="py-2 text-right">基数</th>
|
||||
<th className="py-2 text-left">开始年月</th>
|
||||
<th className="py-2 text-left">截止年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hAdd.map((i: any) => (
|
||||
<tr key={`ha-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe">新增</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
</tr>
|
||||
))}
|
||||
{hSub.map((i: any) => (
|
||||
<tr key={`hs-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger">减少</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">—</td>
|
||||
<td className="py-1.5">{i.endMonth}</td>
|
||||
</tr>
|
||||
))}
|
||||
{hNormal.map((i: any) => (
|
||||
<tr key={`hn-${i.employeeId}`} className="border-b last:border-0">
|
||||
<td className="py-1.5">{i.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{i.department}</td>
|
||||
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500">正常</span></td>
|
||||
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
|
||||
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [sentCode, setSentCode] = useState('')
|
||||
|
||||
const sendCode = async () => {
|
||||
setError('')
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
setError('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/forgot-password/send-code', { phone }) as any
|
||||
setSentCode(res.data?.code || '')
|
||||
setStep(2)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '发送失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPwd = async () => {
|
||||
setError('')
|
||||
if (code.length !== 6) {
|
||||
setError('请输入6位验证码')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('密码至少8位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/auth/forgot-password/verify', { phone, code, newPassword })
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '重置失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">重置密码</h1>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-green-600 mb-3">密码已重置成功</div>
|
||||
<Link to="/login" className="text-primary hover:underline text-sm">返回登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{sentCode && step === 2 && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
验证码:{sentCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入注册手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" disabled={loading} onClick={sendCode}>
|
||||
{loading ? '发送中...' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" value={phone} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>验证码</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="6位验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" disabled={loading} onClick={resetPwd}>
|
||||
{loading ? '重置中...' : '重置密码'}
|
||||
</Button>
|
||||
<button
|
||||
className="w-full text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => { setStep(1); setCode(''); setSentCode('') }}
|
||||
>
|
||||
重新获取验证码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<Link to="/login" className="text-primary hover:underline">返回登录</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { setAuth } = useAuthStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
phone: '13800000001',
|
||||
password: '12345678',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/login', data) as any
|
||||
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">登录</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<Link to="/forgot-password" className="text-primary hover:underline">忘记密码?</Link>
|
||||
<Link to="/register" className="text-primary hover:underline">注册新企业</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: '两次密码不一致',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate()
|
||||
const { setAuth } = useAuthStore()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/register', data) as any
|
||||
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '注册失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-xl font-bold">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h1 className="text-lg font-semibold mb-4">注册新企业</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>企业名称</Label>
|
||||
<Input
|
||||
placeholder="请输入企业名称"
|
||||
{...register('orgName')}
|
||||
/>
|
||||
{errors.orgName && <p className="text-xs text-red-500 mt-1">{errors.orgName.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
{...register('password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>确认密码</Label>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请再次输入密码"
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-xs text-red-500 mt-1">{errors.confirmPassword.message}</p>}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
已有账号?<Link to="/login" className="text-primary hover:underline">去登录</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { PenTool, Check, AlertCircle } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export default function ContractConfirm() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(false)
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [devCode, setDevCode] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/contract-confirm/${token}`).then((res: any) => {
|
||||
setData(res.data)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效或已过期')
|
||||
}).finally(() => setLoading(false))
|
||||
} else {
|
||||
setError('缺少 token 参数')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setSendingCode(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/contract-confirm/send-code', { token }) as any
|
||||
setCodeSent(true)
|
||||
setDevCode(res.data?.data?.code || '')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '验证码发送失败')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
|
||||
setConfirmed(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '确认失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-sm font-semibold mb-2">合同签署确认成功</h1>
|
||||
<p className="text-sm text-gray-500">已记录您的签署确认时间和 IP 地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<PenTool className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">合同签署确认</h1>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
) : data ? (
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.orgName} 与 {data.employeeName} 的劳动合同
|
||||
</div>
|
||||
|
||||
{data.contract && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={data.contract.contractType === 'FIXED' ? '固定期限' : data.contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
{data.contract.contractYears > 0 && <Row label="合同期限" value={`${data.contract.contractYears}年`} />}
|
||||
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
|
||||
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
|
||||
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(data.contract.probationSalary))}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
|
||||
我已阅读合同内容,确认签署
|
||||
</label>
|
||||
|
||||
{/* 验证码区域 */}
|
||||
{agreed && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="请输入6位验证码"
|
||||
maxLength={6}
|
||||
className="flex-1 px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || codeSent}
|
||||
className="px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{sendingCode ? '发送中' : codeSent ? '已发送' : '发送验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{devCode && (
|
||||
<div className="text-xs text-blue-500">开发模式验证码:{devCode}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting || !verifyCode}>
|
||||
{submitting ? '确认中...' : '确认签署'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 确认后将记录签署时间、IP 地址和设备信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FileText, AlertCircle, Check, RefreshCw } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export default function MyContract() {
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resendMsg, setResendMsg] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['my-contract'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/contract') as any
|
||||
return res.data?.data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
const contract = data
|
||||
|
||||
const daysToExpire = contract?.endDate
|
||||
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: null
|
||||
|
||||
const isConfirmed = contract?.attachmentName?.startsWith('confirmed:')
|
||||
|
||||
const handleResend = async () => {
|
||||
setResending(true)
|
||||
setResendMsg('')
|
||||
try {
|
||||
const res = await portalApi.post('/contract-confirm/resend', { contractId: contract?.id }) as any
|
||||
setResendMsg(res.data?.data?.message || '重发成功')
|
||||
} catch (err: any) {
|
||||
setResendMsg(err.response?.data?.error?.message || '重发失败')
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">我的劳动合同</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/payslip" className="text-sm text-primary">工资条</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !contract ? (
|
||||
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 到期提醒 */}
|
||||
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
您的合同还有 {daysToExpire} 天到期
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row label="合同类型" value={contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
|
||||
<Row label="签订方式" value={contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'} />
|
||||
{contract.signDate && <Row label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />}
|
||||
<Row label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
|
||||
{contract.endDate && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
|
||||
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}年`} />}
|
||||
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
|
||||
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />}
|
||||
</div>
|
||||
|
||||
{/* 签署确认记录 */}
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="font-medium text-sm mb-2">签署记录</h3>
|
||||
{isConfirmed ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" />
|
||||
已确认签署({new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-warning">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
合同尚未确认签署
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{resending ? '重发中...' : '重发确认链接'}
|
||||
</Button>
|
||||
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ClipboardList, Check, FileText, X } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
|
||||
const FILE_TYPES = [
|
||||
{ key: 'ID_CARD_FRONT', label: '身份证正面' },
|
||||
{ key: 'ID_CARD_BACK', label: '身份证反面' },
|
||||
{ key: 'EDUCATION', label: '学历证明' },
|
||||
{ key: 'BANK_CARD', label: '银行卡照片' },
|
||||
{ key: 'OTHER', label: '其他材料' },
|
||||
]
|
||||
|
||||
interface UploadedFile {
|
||||
fileType: string
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export default function Onboarding() {
|
||||
const [params] = useSearchParams()
|
||||
const token = params.get('token') || ''
|
||||
const [orgName, setOrgName] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [currentFileType, setCurrentFileType] = useState('ID_CARD_FRONT')
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
idCard: '',
|
||||
emergencyContact: '',
|
||||
emergencyPhone: '',
|
||||
address: '',
|
||||
bankCard: '',
|
||||
bankName: '',
|
||||
})
|
||||
|
||||
// 获取链接信息
|
||||
useState(() => {
|
||||
if (token) {
|
||||
api.get(`/portal/onboarding/${token}`).then((res: any) => {
|
||||
setOrgName(res.data.orgName)
|
||||
}).catch((err: any) => {
|
||||
setError(err.response?.data?.error?.message || '链接无效')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('fileType', currentFileType)
|
||||
const res = await api.post(`/portal/onboarding/${token}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}) as any
|
||||
setUploadedFiles([...uploadedFiles, res.data.data])
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '文件上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (idx: number) => {
|
||||
setUploadedFiles(uploadedFiles.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
|
||||
setSubmitted(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '提交失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-sm w-full text-center">
|
||||
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
|
||||
<h1 className="text-sm font-semibold mb-2">信息提交成功</h1>
|
||||
<p className="text-sm text-gray-500">HR 将审核您的信息,请耐心等待。</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<ClipboardList className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">入职信息填报</h1>
|
||||
</div>
|
||||
|
||||
{orgName && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
欢迎加入 {orgName}!请填写以下信息:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>姓名 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号 *</Label>
|
||||
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>身份证号 *</Label>
|
||||
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系人</Label>
|
||||
<Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>紧急联系电话</Label>
|
||||
<Input type="tel" value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>住址</Label>
|
||||
<Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>银行卡号</Label>
|
||||
<Input value={form.bankCard} onChange={(e) => setForm({ ...form, bankCard: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>开户行</Label>
|
||||
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
|
||||
{/* 文件上传区域 */}
|
||||
<div className="border-t pt-3">
|
||||
<Label>入职材料上传</Label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{FILE_TYPES.map((ft) => (
|
||||
<button
|
||||
key={ft.key}
|
||||
type="button"
|
||||
onClick={() => setCurrentFileType(ft.key)}
|
||||
className={`px-2 py-1 rounded text-xs ${currentFileType === ft.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600'}`}
|
||||
>
|
||||
{ft.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.pdf,.bmp"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-primary"
|
||||
>
|
||||
{uploading ? '上传中...' : `点击上传${FILE_TYPES.find(f => f.key === currentFileType)?.label || ''}`}
|
||||
</button>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{uploadedFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-2 py-1 bg-gray-50 rounded text-xs">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<FileText className="w-3 h-3 flex-shrink-0 text-gray-400" />
|
||||
<span className="truncate">{FILE_TYPES.find(ft => ft.key === f.fileType)?.label || f.fileType}: {f.fileName}</span>
|
||||
</div>
|
||||
<button onClick={() => removeFile(i)} className="text-gray-400 hover:text-danger flex-shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
|
||||
{loading ? '提交中...' : '提交'}
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center">📌 提交后 HR 将审核您的信息</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { DollarSign, Check, TrendingUp, Download } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const portalApi = api.create({ baseURL: '/api/v1/portal' })
|
||||
portalApi.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('portalToken')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
export default function Payslip() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['payslip', month],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/payslip', { params: { month } }) as any
|
||||
return res.data?.data ?? null
|
||||
},
|
||||
})
|
||||
|
||||
const { data: history } = useQuery<any[]>({
|
||||
queryKey: ['payslip-history'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/payslip/history') as any
|
||||
return res.data?.data ?? []
|
||||
},
|
||||
})
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
|
||||
})
|
||||
|
||||
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
|
||||
|
||||
const handleExport = () => {
|
||||
if (!history || history.length === 0) return
|
||||
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
|
||||
const rows = history.map((p: any) => [
|
||||
p.month,
|
||||
p.baseSalary,
|
||||
p.overtimePay,
|
||||
p.allowance,
|
||||
p.deduction,
|
||||
p.totalPay,
|
||||
p.confirmedAt ? '已确认' : '未确认',
|
||||
])
|
||||
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `工资条_${employee.name || '员工'}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month))
|
||||
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6">
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-6 h-6 text-primary" />
|
||||
<h1 className="text-sm font-semibold">我的工资条</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">{employee.name}</span>
|
||||
<Link to="/portal/contract" className="text-sm text-primary">我的合同</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />趋势
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={!history || history.length === 0}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showHistory && sortedHistory.length > 0 && (
|
||||
<Card className="mb-4">
|
||||
<h3 className="text-xs font-medium mb-3">近 {sortedHistory.length} 个月工资趋势</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedHistory.map((p: any) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !data ? (
|
||||
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">基本工资</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.baseSalary))}</span>
|
||||
</div>
|
||||
{data.overtimePay > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">加班费</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.overtimePay))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.allowance > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">津贴</span>
|
||||
<span className="font-medium">¥{fmt(Number(data.allowance))}</span>
|
||||
</div>
|
||||
)}
|
||||
{data.deduction > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">扣款</span>
|
||||
<span className="font-medium text-danger">-¥{fmt(Number(data.deduction))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">应发合计</span>
|
||||
<span className="text-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.confirmedAt ? (
|
||||
<div className="flex items-center gap-2 text-sm text-safe">
|
||||
<Check className="w-4 h-4" /> 已确认({new Date(data.confirmedAt).toLocaleString()})
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => confirmMutation.mutate(data.id)}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2 } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
export default function PortalLogin() {
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [displayedCode, setDisplayedCode] = useState('')
|
||||
|
||||
const handlePasswordLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/login', { phone, password }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post('/portal/send-code', { phone }) as any
|
||||
setCodeSent(true)
|
||||
setDisplayedCode(res.data.code)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/portal/verify-code', { phone, code }) as any
|
||||
localStorage.setItem('portalToken', res.data.token)
|
||||
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
|
||||
navigate('/portal/payslip')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
<Building2 className="w-8 h-8 text-primary" />
|
||||
<span className="text-base font-bold">用工合规助手 — 员工端</span>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex border-b mb-4">
|
||||
<button
|
||||
onClick={() => { setMode('password'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'password' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>密码登录</button>
|
||||
<button
|
||||
onClick={() => { setMode('code'); setError('') }}
|
||||
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'code' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>验证码登录</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
{mode === 'password' ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>密码</Label>
|
||||
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>验证码</Label>
|
||||
<Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}>
|
||||
{codeSent ? '已发送' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{codeSent && displayedCode && (
|
||||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
验证码:{displayedCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user