init: AI HR Compliance Assistant

This commit is contained in:
freedakgmail
2026-07-23 12:34:43 +08:00
commit 820579e98d
81 changed files with 19327 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用工合规助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3167
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "hr-compliance-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"zustand": "^4.5.0",
"@tanstack/react-query": "^5.51.0",
"react-hook-form": "^7.52.0",
"@hookform/resolvers": "^3.9.0",
"zod": "^3.23.0",
"lucide-react": "^0.428.0",
"qrcode.react": "^4.0.1",
"clsx": "^2.1.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+83
View File
@@ -0,0 +1,83 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import MobileTabBar from './components/layout/MobileTabBar'
import PageContainer from './components/layout/PageContainer'
import Login from './pages/auth/Login'
import Register from './pages/auth/Register'
import ForgotPassword from './pages/auth/ForgotPassword'
import Dashboard from './pages/Dashboard'
import Contracts from './pages/Contracts'
import Money from './pages/Money'
import Roster from './pages/Roster'
import Termination from './pages/Termination'
import AIAssistant from './pages/AIAssistant'
import Settings from './pages/Settings'
import PortalLogin from './pages/portal/PortalLogin'
import Payslip from './pages/portal/Payslip'
import MyContract from './pages/portal/MyContract'
import Onboarding from './pages/portal/Onboarding'
import ContractConfirm from './pages/portal/ContractConfirm'
import OnboardingGuide from './components/OnboardingGuide'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (isAuthenticated) return <Navigate to="/" replace />
return <>{children}</>
}
function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
<TopNav />
<main className="flex-1 py-6 pb-20 md:pb-6">
<PageContainer>{children}</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
</div>
)
}
function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-surface">
<main className="max-w-md mx-auto py-6 px-4">{children}</main>
</div>
)
}
export default function App() {
return (
<Routes>
{/* 管理端认证页面 */}
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
<Route path="/forgot-password" element={<PublicRoute><ForgotPassword /></PublicRoute>} />
{/* 管理端业务页面 */}
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
{/* 员工端 */}
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
<Route path="/portal/payslip" element={<PortalLayout><Payslip /></PortalLayout>} />
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} />
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} />
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { X, ArrowRight } from 'lucide-react'
const STORAGE_KEY = 'hr-onboarding-completed'
const steps = [
{
icon: '🏠',
title: '这里看风险',
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
},
{
icon: '',
title: '这里管花名册',
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
},
{
icon: '💰',
title: '这里算薪税',
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
},
]
export default function OnboardingGuide() {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY)
if (!completed) {
setVisible(true)
}
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEY, '1')
setVisible(false)
}
if (!visible) return null
const current = steps[step]
const isLast = step === steps.length - 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,36 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, FileText, Users, Calculator, UserX, Bot } from 'lucide-react'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '花名册', icon: Users },
{ path: '/money', label: '薪税', icon: Calculator },
{ path: '/termination', label: '解聘', icon: UserX },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
]
export default function MobileTabBar() {
const location = useLocation()
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around items-center h-14 z-50">
{tabs.map((tab) => {
const Icon = tab.icon
const active = location.pathname === tab.path
return (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full',
active ? 'text-primary' : 'text-gray-400',
)}
>
<Icon className="w-5 h-5" />
<span className="text-xs">{tab.label}</span>
</Link>
)
})}
</nav>
)
}
@@ -0,0 +1,10 @@
import { ReactNode } from 'react'
import clsx from 'clsx'
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx('max-w-content mx-auto px-4', className)}>
{children}
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Building2, AlertCircle, ChevronDown } from 'lucide-react'
import { useState } from 'react'
import { useAuthStore } from '../../store/authStore'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '花名册' },
{ path: '/money', label: '薪税' },
{ path: '/termination', label: '解聘' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
export default function TopNav() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="hidden sm:inline"></span>
</Link>
<nav className="hidden md:flex items-center gap-1 flex-1">
{tabs.map((tab) => (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
location.pathname === tab.path
? 'bg-primary/10 text-primary'
: 'text-gray-600 hover:bg-gray-100',
)}
>
{tab.label}
{tab.path === '/' && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger text-white text-xs rounded-full flex items-center justify-center hidden">
0
</span>
)}
</Link>
))}
</nav>
<div className="relative shrink-0">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-gray-100"
>
<span className="text-sm text-gray-700 hidden sm:inline">{user?.name || '用户'}</span>
<ChevronDown className="w-4 h-4 text-gray-400" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-40 bg-white rounded-md shadow-lg border border-gray-200 z-20">
<Link
to="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setMenuOpen(false)}
>
</Link>
<button
onClick={() => {
logout()
navigate('/login')
}}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
退
</button>
</div>
</>
)}
</div>
</div>
</header>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { ButtonHTMLAttributes } from 'react'
import clsx from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export default function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) {
return (
<button
className={clsx(
'inline-flex items-center justify-center font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
'bg-danger text-white hover:bg-red-700': variant === 'danger',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-6 py-3 text-base': size === 'lg',
},
className,
)}
{...props}
>
{children}
</button>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { HTMLAttributes } from 'react'
import clsx from 'clsx'
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
{children}
</div>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { ReactNode } from 'react'
import { Inbox } from 'lucide-react'
import Button from './Button'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
actionLabel?: string
onAction?: () => void
}
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-gray-300 mb-4">
{icon || <Inbox className="w-12 h-12" />}
</div>
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{actionLabel && onAction && (
<Button onClick={onAction}>{actionLabel}</Button>
)}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { InputHTMLAttributes, SelectHTMLAttributes, forwardRef } from 'react'
import clsx from 'clsx'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={clsx(
'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-sm',
className,
)}
{...props}
/>
)
}
)
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...props }, ref) {
return (
<select
ref={ref}
className={clsx(
'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-sm bg-white',
className,
)}
{...props}
>
{children}
</select>
)
}
)
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
}
+43
View File
@@ -0,0 +1,43 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
className?: string
}
export default function Modal({ open, onClose, title, children, className }: ModalProps) {
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
{title && (
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
<h3 className="font-medium text-gray-900">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
)}
<div className="p-5">{children}</div>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import clsx from 'clsx'
type Level = 'high' | 'medium' | 'low' | 'safe'
const colors: Record<Level, string> = {
high: 'bg-danger',
medium: 'bg-warning',
low: 'bg-yellow-400',
safe: 'bg-safe',
}
const labels: Record<Level, string> = {
high: '🔴',
medium: '🟡',
low: '🟡',
safe: '🟢',
}
export default function Signal({ level, label }: { level: Level; label?: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={clsx('w-2 h-2 rounded-full', colors[level])} />
{label && <span className="text-gray-700">{label}</span>}
{!label && <span>{labels[level]}</span>}
</span>
)
}
+38
View File
@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-surface text-gray-900 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
* {
@apply box-border;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary text-white hover:bg-primary-dark;
}
.btn-secondary {
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200;
}
.btn-danger {
@apply btn bg-danger text-white hover:bg-red-700;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
}
.input {
@apply 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-sm;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
}
+47
View File
@@ -0,0 +1,47 @@
import axios from 'axios'
import { useAuthStore } from '../store/authStore'
const api = axios.create({
baseURL: '/api/v1',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
let isRefreshing = false
api.interceptors.response.use(
(response) => response.data,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
if (isRefreshing) return Promise.reject(error)
isRefreshing = true
try {
const refreshToken = useAuthStore.getState().refreshToken
if (!refreshToken) throw new Error('No refresh token')
const res = await axios.post('/api/v1/auth/refresh', { refreshToken })
const newToken = res.data.data.accessToken
useAuthStore.getState().updateToken(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
} catch {
useAuthStore.getState().logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
export default api
+26
View File
@@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)
+277
View File
@@ -0,0 +1,277 @@
import { useState, useRef, useEffect } from 'react'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2 } 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'
type Tab = 'chat' | 'predict' | 'review' | 'case'
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 },
]
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold">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-sm 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 />}
</div>
)
}
function ChatTab() {
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' },
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
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 res = await api.post('/ai/chat', { messages: newMessages }) as any
setMessages([...newMessages, { role: 'assistant', content: res.data.reply }])
} catch (err: any) {
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.response?.data?.error?.message || '请稍后重试'}` }])
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
<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-sm 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-sm 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 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 fetchPrediction = async () => {
setLoading(true)
try {
const res = await api.get('/ai/predict') 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>
{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-sm 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('')
const [loading, setLoading] = useState(false)
const handleReview = async () => {
if (!contractText.trim()) return
setLoading(true)
setResult('')
try {
const res = await api.post('/ai/review', { contractText }) as any
setResult(res.data.result)
} catch (err: any) {
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
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-sm 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>
<h3 className="font-medium mb-3"></h3>
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
</div>
)
}
function CaseTab() {
const [scenario, setScenario] = useState('')
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
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)
}
}
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-sm 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>
<h3 className="font-medium mb-3"></h3>
<div className="text-sm text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
</div>
)
}
+355
View File
@@ -0,0 +1,355 @@
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'
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-4">
<h1 className="text-lg font-semibold"></h1>
<div className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm 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-4">
<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-sm text-gray-500"><span className="text-gray-900">{result.reason}</span></div>
<div className="text-sm 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-sm">
{result.reasonNote}
</div>
) : (
<>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.compMonths}</span></div>
{result.capped && (
<div className="text-sm text-warning"> 312</div>
)}
<div className="text-sm text-gray-500"><span className="text-gray-900">¥{result.wage.toLocaleString()}/</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">¥{result.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
{result.isIllegal ? (
<>
<div className="flex items-center justify-between">
<span className="font-medium text-danger">×2</span>
<span className="text-xl font-bold text-danger">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{result.wage.toLocaleString()} × 2</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<span className="font-medium">{result.reason}</span>
<span className="text-xl font-bold text-primary">¥{result.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{result.wage.toLocaleString()}
{result.noticePay > 0 && <span className="block"> ¥{result.noticePay.toLocaleString()}</span>}
</div>
</>
)}
</div>
{result.reasonNote && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-sm ${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-sm">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
</>
)}
</div>
) : (
<div className="text-gray-400 text-sm"></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-4">
<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-sm text-gray-500"><span className="text-gray-900">{hireDate}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
<div className="text-sm text-gray-500"><span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
<div className="text-sm 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-xl font-bold text-danger">¥{result.totalPay.toLocaleString()}</span>
</div>
<div className="text-xs text-gray-400 mt-1">{result.months} × ¥{monthlyWage.toLocaleString()}</div>
</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>1211</span>
</div>
</div>
) : (
<div className="text-gray-400 text-sm"></div>
)}
</Card>
</div>
)
}
+508
View File
@@ -0,0 +1,508 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, RefreshCw, 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 Signal from '../components/ui/Signal'
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-sm">
<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">
<div className="flex items-center gap-2">
<Signal level={emp.riskLevel} />
<span>{emp.contractStatusText}</span>
</div>
</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-sm 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-sm">
{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-sm">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-sm">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-sm">
<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">
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-full max-w-md 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-400 hover:text-gray-600">
<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-sm text-gray-500">{emp.department}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<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-sm mb-2"></h3>
<div className="space-y-2 text-sm">
{emp.contracts.map((c: any) => (
<div key={c.id} className="bg-gray-50 rounded p-2">
<div className="flex items-center gap-2">
<Signal level={c.riskLevel || 'safe'} />
<span>{c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'}</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-sm 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-sm">
<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-sm">
<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-sm text-center py-4"></div>
)}
</div>
</div>
</div>
</div>
)
}
+359
View File
@@ -0,0 +1,359 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo } 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 Signal from '../components/ui/Signal'
import type { DashboardData } from '../types'
function fmt(n: number) {
return `¥${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
}
export default function Dashboard() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'todos'>('overview')
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') 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'] }),
})
if (isLoading) {
return <div className="text-center py-8 text-gray-400">...</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: 'payroll' as const, label: '薪税', icon: Calculator, badge: payroll?.payslipCount ?? 0 },
{ key: 'todos' as const, label: '待办', icon: ListTodo, badge: data.todos.length },
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{data.greeting}</h1>
<p className="text-sm text-gray-500 mt-0.5">{payroll?.month} </p>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? '刷新中...' : '刷新'}
</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-4 py-2 text-sm 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-4">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{stats.map((stat) => {
const Icon = stat.icon
return (
<Card key={stat.label} className="flex items-center gap-3">
<Icon className={`w-8 h-8 ${stat.color}`} />
<div>
<div className="text-xl font-bold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.label}</div>
</div>
</Card>
)
})}
</div>
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Briefcase className="w-5 h-5" /></h2>
<span className="text-sm text-gray-400">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{activityItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex flex-col items-center p-3 rounded-lg bg-gray-50">
<Icon className={`w-5 h-5 mb-1 ${item.color}`} />
<div className="text-lg font-bold">{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
)
})}
</div>
</Card>
{/* 风险分布 */}
<Card>
<h2 className="font-semibold mb-4"></h2>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary">{data.riskDistribution.contract}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warning">{data.riskDistribution.salary}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger">{data.riskDistribution.termination}</div>
<div className="text-xs text-gray-500 mt-1"></div>
</div>
</div>
</Card>
</div>
)}
{/* 薪税 Tab */}
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<Link to="/money" className="text-sm text-primary hover:underline flex items-center gap-1">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
{payroll && payroll.payslipCount > 0 ? (
<div className="space-y-4">
{/* 工资构成 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></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-sm 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-lg font-bold text-primary">{fmt(payroll.totalPay)}</span>
</div>
{/* 扣减项 */}
<div>
<div className="text-sm font-medium text-gray-600 mb-2"></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-sm 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-lg font-bold text-safe">{fmt(payroll.empNetPay)}</span>
</div>
{/* 企业成本 */}
<div className="border-t pt-3 space-y-2">
<div className="text-sm 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-sm 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-sm 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-sm 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-sm 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-lg font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
</div>
</div>
{/* 工资条确认状态 */}
<div className="flex items-center gap-4 text-sm border-t pt-3">
<span className="text-gray-500"></span>
<span className="text-safe"> {payroll.confirmedPayslips}</span>
<span className="text-warning"> {payroll.unconfirmedPayslips}</span>
<span className="text-gray-400"> {payroll.payslipCount} </span>
</div>
</div>
) : (
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
)}
</Card>
)}
{/* 待办 Tab */}
{activeTab === 'todos' && (
<div className="space-y-4">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold"></h2>
<span className="text-sm text-gray-400">{data.todos.length} </span>
</div>
{data.todos.length === 0 ? (
<EmptyState title="暂无待办" description="所有风险项已处理完毕" />
) : (
<div className="space-y-2">
{data.todos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md hover:bg-gray-50 transition-colors"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Signal level={todo.level} />
<div className="flex flex-col">
<span className="text-sm text-gray-800">{todo.title}</span>
<span className="text-xs text-gray-400 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
<div className="flex items-center gap-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-400"
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-4">
<h2 className="font-semibold flex items-center gap-2"><CheckSquare className="w-5 h-5 text-safe" /></h2>
<span className="text-sm text-gray-400">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-2">
{data.resolvedTodos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-3 py-3 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-3 flex-1">
<Check className="w-4 h-4 text-safe" />
<div className="flex flex-col">
<span className="text-sm text-gray-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-400">{todo.description}</span>
</div>
</Link>
<span className="text-xs text-gray-400">
{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>
)
}
+604
View File
@@ -0,0 +1,604 @@
import { useState, useMemo, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, AlertCircle, Info, Save, Check, Upload, Zap, Bell } 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'
type Tab = 'overtime' | 'social' | 'payslip'
export default function Money() {
const [tab, setTab] = useState<Tab>('overtime')
const tabs: { key: Tab; label: string }[] = [
{ key: 'overtime', label: '加班费计算' },
{ key: 'social', label: '社保公积金' },
{ key: 'payslip', label: '工资条管理' },
]
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm 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 === 'overtime' && <OvertimeCalculator />}
{tab === 'social' && <SocialInsuranceCalculator />}
{tab === 'payslip' && <PayslipManager />}
</div>
)
}
function OvertimeCalculator() {
const queryClient = useQueryClient()
const [monthlyWage, setMonthlyWage] = useState(8000)
const [weekdayHours, setWeekdayHours] = useState(0)
const [weekendHours, setWeekendHours] = useState(0)
const [holidayHours, setHolidayHours] = useState(0)
const [selectedEmployee, setSelectedEmployee] = useState('')
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string; monthlySalary: number }[] }>({
queryKey: ['employees-for-overtime'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/overtime', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
alert('加班费记录已保存')
},
})
const batchImportMutation = useMutation({
mutationFn: (data: any[]) => api.post('/payroll/overtime/batch', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['overtime-records'] })
alert('批量导入成功')
},
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
const lines = text.split('\n').filter(l => l.trim())
const items: any[] = []
const empList = employees?.items || []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim())
const empName = cols[0]
const emp = empList.find(e => e.name === empName)
if (!emp) continue
const salary = Number(emp.monthlySalary) || 8000
items.push({
employeeId: emp.id,
month: cols[4] || month,
monthlyWage: salary,
weekdayHours: Number(cols[1]) || 0,
weekendHours: Number(cols[2]) || 0,
holidayHours: Number(cols[3]) || 0,
})
}
if (items.length > 0) {
batchImportMutation.mutate(items)
} else {
alert('未匹配到员工,请确保CSV第一列为员工姓名')
}
}
reader.readAsText(file)
}
const result = useMemo(() => {
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const total = weekdayPay + weekendPay + holidayPay
const totalHours = weekdayHours + weekendHours + holidayHours
return { hourlyWage, weekdayPay, weekendPay, holidayPay, total, totalHours }
}, [monthlyWage, weekdayHours, weekendHours, holidayHours])
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label></Label>
<Select value={selectedEmployee} onChange={(e) => {
setSelectedEmployee(e.target.value)
const emp = employees?.items.find((x) => x.id === e.target.value)
if (emp) {
const salary = Number(emp.monthlySalary) || 8000
setMonthlyWage(salary)
}
}}>
<option value=""></option>
{employees?.items.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={weekdayHours} onChange={(e) => setWeekdayHours(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={weekendHours} onChange={(e) => setWeekendHours(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="number" value={holidayHours} onChange={(e) => setHolidayHours(Number(e.target.value) || 0)} />
</div>
</div>
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
<div className="space-y-3">
<div className="text-sm text-gray-500"><span className="text-gray-900 font-medium">¥{result.hourlyWage.toFixed(2)}</span></div>
<div className="space-y-2">
<ResultRow label={`工作日 ${weekdayHours}h × 1.5`} value={result.weekdayPay} />
<ResultRow label={`休息日 ${weekendHours}h × 2.0`} value={result.weekendPay} />
<ResultRow label={`节假日 ${holidayHours}h × 3.0`} value={result.holidayPay} />
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{result.total.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
{result.totalHours > 36 && (
<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" />
{result.totalHours}36
</div>
)}
{result.totalHours > 0 && result.totalHours <= 36 && (
<div className="text-sm text-safe">{result.totalHours}36 </div>
)}
{selectedEmployee && (
<Button
className="w-full"
onClick={() => saveMutation.mutate({
employeeId: selectedEmployee,
month,
monthlyWage,
weekdayHours,
weekendHours,
holidayHours,
})}
disabled={saveMutation.isPending}
>
<Save className="w-4 h-4 mr-1" />
{saveMutation.isPending ? '保存中...' : '保存加班费记录'}
</Button>
)}
<div className="border-t pt-3">
<input
ref={fileInputRef}
type="file"
accept=".csv"
className="hidden"
onChange={handleFileUpload}
/>
<Button
variant="secondary"
className="w-full"
onClick={() => fileInputRef.current?.click()}
disabled={batchImportMutation.isPending}
>
<Upload className="w-4 h-4 mr-1" />
{batchImportMutation.isPending ? '导入中...' : '批量导入加班数据(CSV'}
</Button>
<div className="text-xs text-gray-400 mt-1">
CSV格式,,,,
</div>
</div>
</div>
</Card>
</div>
)
}
function ResultRow({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{label}</span>
<span className="font-medium">¥{value.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
)
}
function PayslipManager() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState({
employeeId: '',
baseSalary: 8000,
allowance: 0,
deduction: 0,
})
const { data: employees } = useQuery<{ items: { id: string; name: string; department: string }[] }>({
queryKey: ['employees-for-payslip'],
queryFn: async () => {
const res = await api.get('/employees', { params: { pageSize: 100 } }) as any
return res.data
},
})
const { data: payslips, isLoading } = useQuery<any[]>({
queryKey: ['payslips', month],
queryFn: async () => {
const res = await api.get('/payroll/payslip', { params: { month } }) as any
return res.data
},
})
const generateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/generate', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
setShowCreate(false)
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/payroll/payslip/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslips'] }),
})
const batchGenerateMutation = useMutation({
mutationFn: (data: any) => api.post('/payroll/payslip/batch-generate', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payslips'] })
alert('批量生成完成')
},
})
const confirmedCount = payslips?.filter((p: any) => p.confirmedAt).length || 0
const unconfirmedCount = payslips ? payslips.length - confirmedCount : 0
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3 flex-wrap">
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-40" />
{payslips && payslips.length > 0 && (
<div className="flex gap-2 text-sm">
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600"> {payslips.length} </span>
<span className="px-2 py-0.5 rounded bg-green-50 text-safe"> {confirmedCount}</span>
<span className="px-2 py-0.5 rounded bg-amber-50 text-warning"> {unconfirmedCount}</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button onClick={() => setShowCreate(!showCreate)}></Button>
<Button
variant="secondary"
onClick={() => batchGenerateMutation.mutate({ month })}
disabled={batchGenerateMutation.isPending}
>
<Zap className="w-4 h-4 mr-1" />
{batchGenerateMutation.isPending ? '生成中...' : '一键全员生成'}
</Button>
</div>
</div>
{showCreate && (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label></Label>
<Select value={createForm.employeeId} onChange={(e) => setCreateForm({ ...createForm, employeeId: e.target.value })}>
<option value=""></option>
{employees?.items.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.baseSalary} onChange={(e) => setCreateForm({ ...createForm, baseSalary: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.allowance} onChange={(e) => setCreateForm({ ...createForm, allowance: Number(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="number" value={createForm.deduction} onChange={(e) => setCreateForm({ ...createForm, deduction: Number(e.target.value) || 0 })} />
</div>
</div>
<div className="mt-4 flex gap-2">
<Button
onClick={() => generateMutation.mutate({
employeeId: createForm.employeeId,
month,
baseSalary: createForm.baseSalary,
allowance: createForm.allowance,
deduction: createForm.deduction,
})}
disabled={!createForm.employeeId || generateMutation.isPending}
>
{generateMutation.isPending ? '生成中...' : '确认生成(自动关联加班费)'}
</Button>
<Button variant="secondary" onClick={() => setShowCreate(false)}></Button>
</div>
</Card>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !payslips || payslips.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2"></th>
<th className="py-2"></th>
<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-right"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{payslips.map((p: any) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.employee?.name}</td>
<td className="py-2 text-gray-500">{p.employee?.department}</td>
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-right text-danger">{p.deduction > 0 ? '-¥' + p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '¥0'}</td>
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-2 text-center">
{p.confirmedAt ? (
<span className="inline-flex items-center gap-1 text-safe text-xs">
<Check className="w-3 h-3" />
</span>
) : (
<span className="text-warning text-xs"></span>
)}
</td>
<td className="py-2">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="text-xs text-gray-400 hover:text-danger"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}
function SocialInsuranceCalculator() {
const queryClient = useQueryClient()
const [base, setBase] = useState(8000)
const [showConfig, setShowConfig] = useState(false)
const [configForm, setConfigForm] = useState<any>({})
const { data: config } = useQuery<any>({
queryKey: ['social-config'],
queryFn: async () => {
const res = await api.get('/social/config') as any
return res.data
},
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const updateConfigMutation = useMutation({
mutationFn: (data: any) => api.put('/social/config', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['social-config'] }),
})
useMemo(() => {
if (config) setConfigForm(config)
}, [config])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-medium"></h2>
<Button variant="secondary" size="sm" onClick={() => setShowConfig(!showConfig)}>
{showConfig ? '收起配置' : '配置比例'}
</Button>
</div>
{showConfig && (
<Card>
<h3 className="font-medium mb-4 text-sm">{config?.city || '北京'}</h3>
<div className="grid md:grid-cols-3 gap-3 text-sm">
<div>
<Label></Label>
<Input value={configForm.city || ''} onChange={(e) => setConfigForm({ ...configForm, city: e.target.value })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.pensionOrg || 0} onChange={(e) => setConfigForm({ ...configForm, pensionOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.pensionEmp || 0} onChange={(e) => setConfigForm({ ...configForm, pensionEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.medicalOrg || 0} onChange={(e) => setConfigForm({ ...configForm, medicalOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.medicalEmp || 0} onChange={(e) => setConfigForm({ ...configForm, medicalEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.unemploymentOrg || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.unemploymentEmp || 0} onChange={(e) => setConfigForm({ ...configForm, unemploymentEmp: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.injuryOrg || 0} onChange={(e) => setConfigForm({ ...configForm, injuryOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.maternityOrg || 0} onChange={(e) => setConfigForm({ ...configForm, maternityOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.housingOrg || 0} onChange={(e) => setConfigForm({ ...configForm, housingOrg: Number(e.target.value) })} />
</div>
<div>
<Label>(%)</Label>
<Input type="number" value={configForm.housingEmp || 0} onChange={(e) => setConfigForm({ ...configForm, housingEmp: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={configForm.baseMin || 0} onChange={(e) => setConfigForm({ ...configForm, baseMin: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" value={configForm.baseMax || 0} onChange={(e) => setConfigForm({ ...configForm, baseMax: Number(e.target.value) })} />
</div>
</div>
<div className="mt-4">
<Button size="sm" onClick={() => updateConfigMutation.mutate(configForm)} disabled={updateConfigMutation.isPending}>
{updateConfigMutation.isPending ? '保存中...' : '保存配置'}
</Button>
</div>
</Card>
)}
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4">
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<Button onClick={() => calcMutate()} disabled={isPending}>
<Calculator className="w-4 h-4 mr-1" />
{isPending ? '计算中...' : '开始计算'}
</Button>
{config && (
<div className="text-xs text-gray-400">
{config.city} | {config.baseMin}~{config.baseMax}
</div>
)}
</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-sm text-gray-500">
<span className="text-gray-900 font-medium">¥{result.actualBase.toLocaleString()}</span>
{result.capped && <span className="text-warning ml-2"></span>}
{result.floored && <span className="text-warning ml-2"></span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<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>
{result.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">¥{item.orgAmount.toFixed(2)}</td>
<td className="py-1.5 text-right">¥{item.empAmount.toFixed(2)}</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">¥{result.totalOrg.toFixed(2)}</td>
<td className="py-2 text-right text-warning">¥{result.totalEmp.toFixed(2)}</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-xl font-bold text-primary">¥{result.total.toFixed(2)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{result.totalOrg.toFixed(2)} + ¥{result.totalEmp.toFixed(2)}
</div>
</div>
</div>
) : (
<div className="text-gray-400 text-sm"></div>
)}
</Card>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell } 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'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
queryFn: async () => {
const res = await api.get('/settings/org') as any
return res.data
},
})
const { data: usersData } = useQuery<any>({
queryKey: ['users'],
queryFn: async () => {
const res = await api.get('/settings/users') as any
return res.data
},
})
const updateOrgMutation = useMutation({
mutationFn: (data: any) => api.put('/settings/org', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['org-settings'] }),
})
const sections = [
{ key: 'org' as const, label: '企业信息', icon: Building2 },
{ key: 'users' as const, label: '用户管理', icon: Users },
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
]
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
<div className="flex gap-1 border-b">
{sections.map((s) => {
const Icon = s.icon
return (
<button
key={s.key}
onClick={() => setActiveSection(s.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{s.label}
</button>
)
})}
</div>
{activeSection === 'org' && (
<OrgSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} saving={updateOrgMutation.isPending} />
)}
{activeSection === 'users' && <UserSettings usersData={usersData} />}
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
{activeSection === 'notifications' && <NotificationSettings />}
</div>
)
}
function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data: any) => void; saving: boolean }) {
const [form, setForm] = useState({
name: orgData?.data?.name || '',
contactName: orgData?.data?.contactName || '',
contactPhone: orgData?.data?.contactPhone || '',
})
return (
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4 max-w-md">
<div>
<Label></Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="企业名称" />
</div>
<div>
<Label></Label>
<Input value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} placeholder="联系人姓名" />
</div>
<div>
<Label></Label>
<Input value={form.contactPhone} onChange={(e) => setForm({ ...form, contactPhone: e.target.value })} placeholder="联系电话" />
</div>
<Button onClick={() => onSave(form)} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</Card>
)
}
function UserSettings({ usersData }: { usersData: any }) {
const [showAddModal, setShowAddModal] = useState(false)
const users = usersData?.data || []
return (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<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>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-b last:border-0">
<td className="py-3 px-3 font-medium">{u.name}</td>
<td className="py-3 px-3 text-gray-600">{u.phone}</td>
<td className="py-3 px-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{u.role === 'ADMIN' ? '管理员' : u.role === 'HR' ? 'HR' : '查看者'}
</span>
</td>
<td className="py-3 px-3 text-safe"></td>
</tr>
))}
</tbody>
</table>
</div>
<AddUserModal open={showAddModal} onClose={() => setShowAddModal(false)} />
</Card>
)
}
function AddUserModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [form, setForm] = useState({ name: '', phone: '', password: '', role: 'HR' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async () => {
setLoading(true)
setError('')
try {
await api.post('/settings/users', form)
onClose()
} catch (err: any) {
setError(err.response?.data?.error?.message || '添加失败')
} finally {
setLoading(false)
}
}
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-sm">{error}</div>}
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div>
<Label></Label>
<Select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="HR">HR</option>
<option value="ADMIN"></option>
<option value="VIEWER"></option>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.password}>
{loading ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Modal>
)
}
function PlanSettings({ orgData }: { orgData: any }) {
const plan = orgData?.data?.plan || 'FREE'
const maxEmployees = orgData?.data?.maxEmployees || 10
const plans = [
{ key: 'FREE', label: '免费版', price: '¥0/月', features: ['10人以内', '基础风险检测', '10次AI问答/月'] },
{ key: 'PRO', label: '专业版', price: '¥299/月', features: ['100人以内', '全功能风险检测', '100次AI问答/月', '合同审查'] },
{ key: 'ENTERPRISE', label: '企业版', price: '联系客服', features: ['无限人数', '无限AI问答', '专属客服', 'API接入'] },
]
return (
<div className="grid md:grid-cols-3 gap-4">
{plans.map((p) => (
<Card key={p.key}>
<div className={`px-4 py-3 rounded-t-lg ${plan === p.key ? 'bg-primary text-white' : 'bg-gray-50'}`}>
<div className="font-medium">{p.label}</div>
<div className={`text-lg font-bold ${plan === p.key ? 'text-white' : 'text-gray-900'}`}>{p.price}</div>
</div>
<div className="p-4 space-y-2">
{p.features.map((f, i) => (
<div key={i} className="text-sm text-gray-600 flex items-center gap-2">
<span className="text-safe"></span> {f}
</div>
))}
<div className="pt-2">
{plan === p.key ? (
<div className="text-sm text-center text-primary font-medium"></div>
) : (
<Button variant="secondary" className="w-full" size="sm"></Button>
)}
</div>
</div>
</Card>
))}
</div>
)
}
function NotificationSettings() {
const queryClient = useQueryClient()
const [form, setForm] = useState<any>({})
const [checkResult, setCheckResult] = useState<string>('')
const { data: setting } = useQuery<any>({
queryKey: ['notification-settings'],
queryFn: async () => {
const res = await api.get('/notifications/settings') as any
return res.data
},
})
const { data: logsData } = useQuery<any>({
queryKey: ['notification-logs'],
queryFn: async () => {
const res = await api.get('/notifications/logs', { params: { pageSize: 10 } }) as any
return res.data
},
})
useMemo(() => {
if (setting) setForm(setting)
}, [setting])
const updateMutation = useMutation({
mutationFn: (data: any) => api.put('/notifications/settings', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-settings'] }),
})
const checkMutation = useMutation({
mutationFn: () => api.post('/notifications/check-contracts') as any,
onSuccess: (res: any) => {
setCheckResult(`检查完成:发现 ${res.data.checked} 个即将到期的合同,已发送 ${res.data.notified} 条通知`)
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
},
})
const logs = logsData?.items || []
return (
<div className="space-y-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-4 max-w-md">
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<input type="checkbox" checked={form.contractExpiry ?? true} onChange={(e) => setForm({ ...form, contractExpiry: e.target.checked })} />
</label>
<div>
<Label></Label>
<Input type="number" value={form.expiryDays ?? 30} onChange={(e) => setForm({ ...form, expiryDays: Number(e.target.value) })} />
</div>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<input type="checkbox" checked={form.contractUnsigned ?? true} onChange={(e) => setForm({ ...form, contractUnsigned: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<input type="checkbox" checked={form.overtimeAlert ?? true} onChange={(e) => setForm({ ...form, overtimeAlert: e.target.checked })} />
</label>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<input type="checkbox" checked={form.payslipReady ?? true} onChange={(e) => setForm({ ...form, payslipReady: e.target.checked })} />
</label>
<div className="border-t pt-3 space-y-3">
<div className="text-sm font-medium"></div>
<div className="text-xs text-gray-400"></div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.payrollDay ?? 10} onChange={(e) => setForm({ ...form, payrollDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.socialInsDay ?? 15} onChange={(e) => setForm({ ...form, socialInsDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.housingFundDay ?? 15} onChange={(e) => setForm({ ...form, housingFundDay: Number(e.target.value) })} />
</div>
<div>
<Label></Label>
<Input type="number" min={1} max={28} value={form.taxDay ?? 15} onChange={(e) => setForm({ ...form, taxDay: Number(e.target.value) })} />
</div>
</div>
</div>
<div className="border-t pt-3">
<Label> Webhook</Label>
<Input value={form.wechatWebhook || ''} onChange={(e) => setForm({ ...form, wechatWebhook: e.target.value || null })} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
</div>
<label className="flex items-center justify-between">
<span className="text-sm"></span>
<input type="checkbox" checked={form.emailNotify ?? false} onChange={(e) => setForm({ ...form, emailNotify: e.target.checked })} />
</label>
{form.emailNotify && (
<div>
<Label></Label>
<Input value={form.email || ''} onChange={(e) => setForm({ ...form, email: e.target.value || null })} placeholder="hr@example.com" />
</div>
)}
<Button onClick={() => updateMutation.mutate(form)} disabled={updateMutation.isPending}>
{updateMutation.isPending ? '保存中...' : '保存设置'}
</Button>
</div>
</Card>
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="font-medium"></h2>
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
{checkMutation.isPending ? '检查中...' : '立即检查'}
</Button>
</div>
{checkResult && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm mb-3">{checkResult}</div>
)}
{logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log: any) => (
<div key={log.id} className="text-sm border-b last:border-0 py-2">
<div className="font-medium">{log.title}</div>
<div className="text-gray-500 text-xs mt-0.5">{log.content}</div>
<div className="text-gray-400 text-xs mt-0.5">{new Date(log.createdAt).toLocaleString('zh-CN')}</div>
</div>
))}
</div>
) : (
<div className="text-gray-400 text-sm text-center py-4"></div>
)}
</Card>
</div>
)
}
+709
View File
@@ -0,0 +1,709 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer } 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 Signal from '../components/ui/Signal'
const REASONS = [
{ value: 'NEGOTIATED', label: '协商解除(双方同意分开了)', legalBasis: '《劳动合同法》第36条' },
{ value: 'FAULT', label: '员工犯错被辞退(严重违纪/失职等)', legalBasis: '《劳动合同法》第39条' },
{ value: 'NONFAULT', label: '员工没犯错但干不了(生病/不胜任等)', legalBasis: '《劳动合同法》第40条' },
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2', legalBasis: '《劳动合同法》第87条' },
]
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '确认完成']
interface RosterEmployee {
id: string
name: string
department: string
status: string
hireDate: string
monthlySalary: number
latestContract: any
counts: any
}
interface EmployeeProfile {
id: string
name: string
department: string
status: string
hireDate: string
monthlySalary: number
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
contracts: any[]
disciplinaryRecords: any[]
attendanceRecords: any[]
performanceRecords: any[]
trainingRecords: any[]
}
export default function Termination() {
const queryClient = useQueryClient()
const [step, setStep] = useState(0)
const [reason, setReason] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [terminationDate, setTerminationDate] = useState('')
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
const [socialAvgWage, setSocialAvgWage] = useState(0)
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data
},
})
const selectedEmployee = employees?.find((e) => e.id === employeeId)
const { data: profile } = useQuery<EmployeeProfile>({
queryKey: ['employee-profile', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/profile`) as any
return res.data
},
enabled: !!employeeId,
})
// 根据员工数据生成解聘建议
const suggestions = useMemo(() => {
if (!profile) return []
const list: { reason: string; label: string; why: string }[] = []
// 有违纪记录 → 建议过错解除
if (profile.disciplinaryRecords?.length > 0) {
const severe = profile.disciplinaryRecords.filter((d) => d.action === 'TERMINATION' || d.type === 'INSUBORDINATION' || d.type === 'MISCONDUCT')
if (severe.length > 0) {
list.push({ reason: 'FAULT', label: '过错解除', why: `${severe.length}条严重违纪记录,可依据规章制度解除` })
} else {
list.push({ reason: 'FAULT', label: '过错解除', why: `${profile.disciplinaryRecords.length}条违纪记录,可考虑过错解除` })
}
}
// 绩效不佳 → 建议非过错解除
const badPerf = profile.performanceRecords?.filter((p) => p.result === 'NEED_IMPROVE' || p.result === 'UNQUALIFIED')
if (badPerf?.length > 0) {
const hasTraining = profile.trainingRecords?.length > 0
list.push({
reason: 'NONFAULT',
label: '非过错解除',
why: hasTraining
? `${badPerf.length}次绩效不佳且已培训/调岗,可按不胜任解除`
: `${badPerf.length}次绩效不佳,需先培训或调岗后才能按不胜任解除`,
})
}
// 合同到期 → 建议不续签
const latestContract = profile.contracts?.[0]
if (latestContract?.endDate) {
const daysToExpire = Math.floor((new Date(latestContract.endDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
if (daysToExpire <= 30 && daysToExpire >= -90) {
list.push({ reason: 'EXPIRED', label: '合同到期不续签', why: `合同将于${latestContract.endDate.slice(0, 10)}到期,可选择不续签` })
}
}
// 未签合同 → 提示双倍工资风险
if (!latestContract?.signDate || latestContract?.contractType === 'UNSIGNED') {
const days = Math.floor((new Date().getTime() - new Date(profile.hireDate).getTime()) / (1000 * 60 * 60 * 24))
if (days > 30) {
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: `未签合同已${days}天,协商解除可同时解决双倍工资问题` })
}
}
// 孕期/哺乳期/工伤 → 风险提示
if (profile.isPregnant) list.push({ reason: '', label: '⚠️ 孕期禁止解除', why: '该员工在孕期/哺乳期,法律禁止以非过错理由解除' })
if (profile.isWorkInjured) list.push({ reason: '', label: '⚠️ 工伤期间禁止解除', why: '工伤期间不得解除劳动合同' })
if (profile.isInMedicalPeriod) list.push({ reason: '', label: '⚠️ 医疗期保护', why: '医疗期内不得以非过错理由解除' })
// 默认推荐协商解除
if (list.length === 0 || !list.some((s) => s.reason !== '')) {
list.push({ reason: 'NEGOTIATED', label: '协商解除', why: '无特殊风险因素,推荐协商解除,成本最低、风险最小' })
}
return list
}, [profile])
const { data: checklistItems } = useQuery<{ key: string; label: string }[]>({
queryKey: ['checklist', reason],
queryFn: async () => {
const res = await api.get(`/termination/checklist/${reason}`) as any
return res.data
},
enabled: !!reason && step >= 2,
})
const { data: riskAssessment } = useQuery<{ level: string; warnings: string[] }>({
queryKey: ['assess', employeeId, reason],
queryFn: async () => {
const res = await api.get(`/termination/assess/${employeeId}`, { params: { reason } }) as any
return res.data
},
enabled: !!employeeId && !!reason && step >= 1,
})
const saveMutation = useMutation({
mutationFn: (data: any) => api.post('/termination', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['employees'] })
setStep(4)
},
})
const { data: evidenceChain } = useQuery({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
return res.data
},
enabled: !!employeeId && step === 4 && saveMutation.isSuccess,
})
const reasonLabel = REASONS.find((r) => r.value === reason)?.label || ''
const reasonLegalBasis = REASONS.find((r) => r.value === reason)?.legalBasis || ''
const costResult = useMemo(() => {
if (!selectedEmployee || !terminationDate) return null
const hire = new Date(selectedEmployee.hireDate)
const leave = new Date(terminationDate)
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
const wage = selectedEmployee.monthlySalary || 0
let capped = false
let cappedWage = wage
let cappedMonths = compMonths
if (socialAvgWage > 0 && wage > socialAvgWage * 3) {
cappedWage = socialAvgWage * 3
cappedMonths = Math.min(compMonths, 12)
capped = true
}
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
NEGOTIATED: { multiplier: 1, notice: false },
FAULT: { multiplier: 0, notice: false },
NONFAULT: { multiplier: 1, notice: true },
LAYOFF: { multiplier: 1, notice: false },
EXPIRED: { multiplier: 1, notice: false },
ILLEGAL: { multiplier: 2, notice: false },
}
const r = reasonMap[reason] || { multiplier: 1, notice: false }
const basePay = cappedWage * cappedMonths
const severancePay = basePay * r.multiplier
const noticePay = r.notice ? cappedWage : 0
const totalSeverance = severancePay + noticePay
// 双倍工资计算(未签合同)
const contract = selectedEmployee.latestContract
const hasContract = contract && contract.signDate && contract.contractType !== 'UNSIGNED'
let doublePay = 0
let doubleMonths = 0
let doubleStartDate = ''
let doubleEndDate = ''
if (!hasContract) {
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 (leave < endDate) endDate = leave
doubleMonths = Math.min(
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
11,
)
doubleMonths = Math.max(doubleMonths, 0)
doublePay = wage * doubleMonths
doubleStartDate = startDate.toISOString().slice(0, 10)
doubleEndDate = endDate.toISOString().slice(0, 10)
}
return {
years, remainingMonths, compMonths, wage, cappedWage, cappedMonths, capped,
basePay, severancePay, noticePay, totalSeverance,
doublePay, doubleMonths, doubleStartDate, doubleEndDate, hasContract,
noComp: r.multiplier === 0,
isIllegal: r.multiplier === 2,
grandTotal: totalSeverance + doublePay,
}
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
const canProceed = () => {
if (step === 0) return !!employeeId
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
if (step === 2) return true
if (step === 3) return true
return false
}
const handleSave = () => {
saveMutation.mutate({
employeeId,
reason,
terminationDate: new Date(terminationDate).toISOString(),
compensation: costResult?.totalSeverance || 0,
checklist,
remark: '',
})
}
const handleReset = () => {
setStep(0)
setReason('')
setEmployeeId('')
setTerminationDate('')
setChecklist({})
setAcknowledgeRisk(false)
}
return (
<div className="space-y-4">
<h1 className="text-lg font-semibold"></h1>
{/* 进度条 */}
<div className="flex items-center gap-1">
{STEPS.map((s, i) => (
<div key={i} className="flex items-center">
<div className={`w-2.5 h-2.5 rounded-full ${i <= step ? 'bg-primary' : 'bg-gray-300'}`} />
{i < STEPS.length - 1 && <div className={`w-8 h-0.5 ${i < step ? 'bg-primary' : 'bg-gray-300'}`} />}
</div>
))}
</div>
<Card>
<div className="mb-2 text-sm text-gray-500">Step {step + 1}/5{STEPS[step]}</div>
{/* Step 1: 选择员工 */}
{step === 0 && (
<div className="space-y-4">
<div>
<Label></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{employees?.map((emp) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
))}
</Select>
</div>
{selectedEmployee && (
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee.name}{selectedEmployee.department}</div>
<div>{selectedEmployee.hireDate?.toString().slice(0, 10)}</div>
<div>¥{selectedEmployee.monthlySalary.toLocaleString()}</div>
{selectedEmployee.latestContract ? (
<div>{selectedEmployee.latestContract.contractType === 'UNSIGNED' ? '未签订' : `签订于 ${selectedEmployee.latestContract.signDate?.slice(0, 10) || '未知'}`}</div>
) : (
<div className="text-warning"> </div>
)}
{selectedEmployee.counts && (
<div className="flex gap-3 flex-wrap mt-2">
{selectedEmployee.counts.disciplinaryRecords > 0 && (
<span className="text-danger">{selectedEmployee.counts.disciplinaryRecords}</span>
)}
{selectedEmployee.counts.performanceRecords > 0 && (
<span>{selectedEmployee.counts.performanceRecords}</span>
)}
{selectedEmployee.counts.attendanceRecords > 0 && (
<span>{selectedEmployee.counts.attendanceRecords}</span>
)}
</div>
)}
</div>
)}
{profile && suggestions.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">📋 </div>
{suggestions.map((s, i) => (
<div
key={i}
className={`px-3 py-2 rounded-md text-sm ${s.reason === '' ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}
>
<div className="font-medium">{s.label}</div>
<div className="text-xs mt-0.5">{s.why}</div>
</div>
))}
</div>
)}
{employeeId && !profile && (
<div className="text-sm text-gray-400">...</div>
)}
</div>
)}
{/* Step 2: 解聘方式 */}
{step === 1 && (
<div className="space-y-4">
{suggestions.length > 0 && (
<div className="bg-blue-50 rounded-md p-3 space-y-1">
<div className="text-sm font-medium text-blue-700">💡 </div>
{suggestions.filter((s) => s.reason).map((s, i) => (
<div key={i} className="text-xs text-blue-600">
{s.label}{s.why}
</div>
))}
</div>
)}
<div className="space-y-2">
{REASONS.map((r) => {
const suggested = suggestions.find((s) => s.reason === r.value)
return (
<label
key={r.value}
className={`flex items-start gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50 ${suggested ? 'border-primary bg-primary/5' : ''}`}
>
<input type="radio" name="reason" value={r.value} checked={reason === r.value} onChange={(e) => setReason(e.target.value)} className="mt-0.5" />
<div className="flex-1">
<div className="text-sm flex items-center gap-2">
{r.label}
{suggested && <span className="text-xs text-primary font-medium"></span>}
</div>
{suggested && (
<div className="text-xs text-gray-500 mt-0.5">{suggested.why}</div>
)}
</div>
</label>
)
})}
</div>
<div>
<Label></Label>
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
</div>
{/* 禁止解聘检查 */}
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="space-y-2">
{riskAssessment.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
{w}
</div>
))}
<label className="flex items-center gap-2 text-sm px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
</label>
</div>
)}
</div>
)}
{/* Step 3: 合规检查 */}
{step === 2 && (
<div className="space-y-3">
{checklistItems?.map((item) => (
<label key={item.key} className="flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={checklist[item.key] || false}
onChange={(e) => setChecklist({ ...checklist, [item.key]: e.target.checked })}
/>
<span className="text-sm">{item.label}</span>
</label>
))}
</div>
)}
{/* Step 4: 费用结算 */}
{step === 3 && (
<div className="space-y-4">
<div>
<Label></Label>
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
</div>
{costResult && (
<div className="space-y-4">
{/* 员工概况 */}
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded-md space-y-1">
<div className="font-medium">{selectedEmployee?.name}{selectedEmployee?.department}</div>
<div>{costResult.years}{costResult.remainingMonths}</div>
<div>¥{costResult.wage.toLocaleString()}/</div>
{costResult.capped && (
<div className="text-warning"> 312</div>
)}
</div>
{/* 经济补偿金 / 赔偿金 */}
{costResult.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-sm">
</div>
) : (
<div className="border rounded-md p-4 space-y-2">
<div className="font-medium flex items-center gap-2">
<Calculator className="w-4 h-4" />
{costResult.isIllegal ? '违法解除赔偿金' : '经济补偿金'}
</div>
<div className="text-sm text-gray-500">{costResult.cappedMonths}</div>
<div className="text-sm text-gray-500">¥{costResult.cappedWage.toLocaleString()}/</div>
{costResult.isIllegal && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"></span>
<span>¥{costResult.basePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="font-medium">{costResult.isIllegal ? '赔偿金(×2' : '补偿金'}</span>
<span className={`text-lg font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</span>
</div>
{costResult.noticePay > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500"></span>
<span>¥{costResult.noticePay.toLocaleString()}</span>
</div>
)}
{costResult.noticePay > 0 && (
<div className="text-xs text-gray-400"> ¥{costResult.noticePay.toLocaleString()}</div>
)}
{costResult.isIllegal && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>287</span>
</div>
)}
</div>
)}
{/* 双倍工资(未签合同自动触发) */}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="border border-warning rounded-md p-4 space-y-2">
<div className="font-medium flex items-center gap-2 text-warning">
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-sm text-gray-500">{costResult.doubleStartDate}</div>
<div className="text-sm text-gray-500">{costResult.doubleEndDate}</div>
<div className="text-sm text-gray-500">{costResult.doubleMonths}</div>
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-warning">¥{costResult.doublePay.toLocaleString()}</span>
</div>
<div className="text-xs text-gray-400">{costResult.doubleMonths} × ¥{costResult.wage.toLocaleString()}</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-800 text-xs">
<Info className="w-3 h-3 mt-0.5 shrink-0" />
<span>1211</span>
</div>
</div>
)}
{/* 合计 */}
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-danger">¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
</div>
</div>
{!costResult.noComp && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
)}
</div>
)}
</div>
)}
{/* Step 5: 解聘材料 */}
{step === 4 && (
<div className="space-y-4">
{saveMutation.isError ? (
<div className="text-center py-8">
<AlertTriangle className="w-12 h-12 text-danger mx-auto" />
<div className="text-danger font-medium mt-2"></div>
<div className="text-sm text-gray-500">{(saveMutation.error as any)?.response?.data?.error?.message || '请稍后重试'}</div>
<Button onClick={() => setStep(3)} className="mt-4"></Button>
</div>
) : saveMutation.isPending ? (
<div className="text-center py-8 text-gray-400">...</div>
) : (
<div className="space-y-4">
{/* 成功提示 */}
<div className="flex items-center gap-2 text-safe">
<Check className="w-5 h-5" />
<span className="font-medium"></span>
</div>
{/* 打印按钮 */}
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => window.print()}>
<Printer className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={handleReset}>
</Button>
</div>
{/* 1. 解聘通知书 */}
<div className="border rounded-lg p-6 space-y-4 print:shadow-none">
<div className="text-center">
<h2 className="text-lg font-bold"></h2>
</div>
<div className="text-sm text-gray-700 space-y-3">
<p><strong>{selectedEmployee?.name}</strong> /</p>
<p>
<strong>{selectedEmployee?.hireDate?.toString().slice(0, 10)}</strong> {selectedEmployee?.department}
<strong>{reasonLabel}</strong> <strong>{terminationDate}</strong>
</p>
<p>
{reasonLegalBasis}
</p>
{costResult && !costResult.noComp && (
<p>
<strong>{costResult.cappedMonths}</strong> <strong>¥{costResult.cappedWage.toLocaleString()}/</strong>
<strong>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong>
{costResult.noticePay > 0 && `(含代通知金 ¥${costResult.noticePay.toLocaleString()}`}
</p>
)}
{costResult && costResult.noComp && (
<p></p>
)}
{costResult && !costResult.hasContract && costResult.doubleMonths > 0 && (
<p>
{costResult.doubleMonths} <strong>¥{costResult.doublePay.toLocaleString()}</strong>
</p>
)}
{costResult && (
<p><strong>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
)}
<p></p>
<div className="text-right mt-6 space-y-1">
<p></p>
<p className="text-gray-400">{new Date().toISOString().slice(0, 10)}</p>
</div>
</div>
</div>
{/* 2. 费用结算明细 */}
{costResult && (
<div className="border rounded-lg p-4 space-y-2">
<h3 className="font-medium flex items-center gap-2"><Calculator className="w-4 h-4" /></h3>
<div className="text-sm space-y-1">
<div className="flex justify-between"><span></span><span>{costResult.years}{costResult.remainingMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{costResult.wage.toLocaleString()}/</span></div>
{costResult.capped && <div className="text-warning"> 312</div>}
{!costResult.noComp && (
<>
<div className="flex justify-between"><span></span><span>{costResult.cappedMonths}</span></div>
<div className="flex justify-between"><span></span><span>¥{costResult.cappedWage.toLocaleString()}/</span></div>
<div className="flex justify-between font-medium"><span>{costResult.isIllegal ? '违法解除赔偿金(×2' : '经济补偿金'}</span><span>¥{costResult.severancePay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
{costResult.noticePay > 0 && <div className="flex justify-between"><span></span><span>¥{costResult.noticePay.toLocaleString()}</span></div>}
</>
)}
{!costResult.hasContract && costResult.doubleMonths > 0 && (
<div className="flex justify-between text-warning"><span>{costResult.doubleMonths}</span><span>¥{costResult.doublePay.toLocaleString()}</span></div>
)}
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span></span><span>¥{costResult.grandTotal.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
</div>
</div>
)}
{/* 3. 合规检查清单 */}
<div className="border rounded-lg p-4 space-y-2">
<h3 className="font-medium flex items-center gap-2"><Shield className="w-4 h-4" /></h3>
<div className="text-sm space-y-1">
{checklistItems?.map((item) => (
<div key={item.key} className="flex items-center gap-2">
<span className={checklist[item.key] ? 'text-safe' : 'text-danger'}>
{checklist[item.key] ? '✓' : '✗'}
</span>
<span className={checklist[item.key] ? '' : 'text-gray-500'}>{item.label}</span>
</div>
))}
{riskAssessment && riskAssessment.warnings.length > 0 && (
<div className="mt-2 space-y-1">
{riskAssessment.warnings.map((w, i) => (
<div key={i} className="flex items-center gap-2 text-danger">
<AlertTriangle className="w-3 h-3" />{w}
</div>
))}
</div>
)}
</div>
</div>
{/* 4. 仲裁证据链 */}
<div className="border rounded-lg p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><FileText className="w-4 h-4" /></h3>
{evidenceChain ? (
<>
<div className="text-xs text-gray-500">
{evidenceChain.summary?.total || 0}
{evidenceChain.summary?.signed || 0}
{evidenceChain.summary?.unsigned || 0}
</div>
{(() => {
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
(acc[e.category] = acc[e.category] || []).push(e)
return acc
}, {})
return Object.entries(grouped).map(([category, items]) => (
<div key={category} className="space-y-1">
<div className="text-sm font-medium text-gray-700">{category as string}</div>
{(items as any[]).map((e: any, i: number) => (
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
<div className="flex items-center gap-2">
<span>{e.title}</span>
{e.acknowledged === true && <span className="text-safe"></span>}
{e.acknowledged === false && <span className="text-danger"></span>}
</div>
<div className="text-gray-400">{e.description}</div>
</div>
))}
</div>
))
})()}
</>
) : (
<div className="text-sm text-gray-400">...</div>
)}
</div>
</div>
)}
</div>
)}
{/* 导航按钮 */}
{step < 4 && (
<div className="flex justify-between mt-6">
<Button
variant="secondary"
onClick={() => setStep(Math.max(0, step - 1))}
disabled={step === 0}
>
<ChevronLeft className="w-4 h-4 mr-1" />
</Button>
{step < 3 ? (
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
) : (
<Button onClick={handleSave} disabled={saveMutation.isPending}>
<Shield className="w-4 h-4 mr-1" />
</Button>
)}
</div>
)}
</Card>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { 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 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}$/, '手机号格式不正确'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
}).refine((data) => data.newPassword.length >= 8, {
message: '密码至少8位',
path: ['newPassword'],
})
type FormData = z.infer<typeof schema>
export default function ForgotPassword() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormData) => {
setError('')
setLoading(true)
try {
await api.post('/auth/reset-password', data)
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>
)}
<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="至少8位"
{...register('newPassword')}
/>
<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.newPassword && <p className="text-xs text-red-500 mt-1">{errors.newPassword.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>
)
}
+107
View File
@@ -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>
)
}
+126
View File
@@ -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,114 @@
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'
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)
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 handleConfirm = async () => {
setSubmitting(true)
try {
await api.post('/portal/contract-confirm', { token, agreed: true })
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-lg 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-lg 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-4">
<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={`¥${Number(data.contract.probationSalary).toLocaleString()}`} />}
</div>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
</label>
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting}>
{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>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { FileText, AlertCircle, Check } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import EmptyState from '../../components/ui/EmptyState'
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 { 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
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-lg 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-4">
{/* 到期提醒 */}
{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={`¥${Number(contract.probationSalary).toLocaleString()}`} />}
</div>
{/* 签署确认记录 */}
<div className="border-t pt-3">
<h3 className="font-medium text-sm mb-2"></h3>
{contract.attachmentName?.startsWith('confirmed:') ? (
<div className="flex items-center gap-2 text-sm text-safe">
<Check className="w-4 h-4" />
{new Date(contract.attachmentName.slice(10)).toLocaleString()}
</div>
) : (
<div className="text-sm text-gray-400"></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>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ClipboardList, Check } 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'
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 [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 handleSubmit = async () => {
setError('')
setLoading(true)
try {
await api.post('/portal/onboarding', { ...form, token })
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-lg 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-lg 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-4">
<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>
<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>
)
}
+116
View File
@@ -0,0 +1,116 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { DollarSign, Check } 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 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 { 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 confirmMutation = useMutation({
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
})
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
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-lg 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="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"
/>
</div>
<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">¥{Number(data.baseSalary).toLocaleString()}</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">¥{Number(data.overtimePay).toLocaleString()}</span>
</div>
</div>
)}
{data.allowance > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{Number(data.allowance).toLocaleString()}</span>
</div>
)}
{data.deduction > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">-¥{Number(data.deduction).toLocaleString()}</span>
</div>
)}
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="font-medium"></span>
<span className="text-xl font-bold text-primary">¥{Number(data.totalPay).toLocaleString()}</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>
)
}
+129
View File
@@ -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-xl 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-4">
<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-4">
<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>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
orgId: string
name: string
phone: string
role: 'ADMIN' | 'HR' | 'VIEWER'
}
interface AuthState {
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
setAuth: (user: User, accessToken: string, refreshToken: string) => void
updateToken: (accessToken: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
updateToken: (accessToken) => set({ accessToken }),
logout: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' },
),
)
+163
View File
@@ -0,0 +1,163 @@
export interface ApiResponse<T> {
success: boolean
data: T
error: { code: string; message: string } | null
}
export interface PaginatedData<T> {
items: T[]
total: number
page: number
pageSize: number
}
export interface Organization {
id: string
name: string
plan: 'FREE' | 'PRO' | 'ENTERPRISE'
maxEmployees: number
city: string | null
}
export interface User {
id: string
orgId: string
name: string
phone: string
email: string | null
role: 'ADMIN' | 'HR' | 'VIEWER'
}
export interface Employee {
id: string
orgId: string
name: string
department: string
hireDate: string
monthlySalary: string
status: 'ACTIVE' | 'RESIGNED'
gender: string | null
phone: string | null
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
contracts: LaborContract[]
}
export interface LaborContract {
id: string
employeeId: string
signDate: string | null
startDate: string
endDate: string | null
contractType: 'FIXED' | 'UNFIXED' | 'UNSIGNED'
signMethod: 'PAPER' | 'ELECTRONIC'
contractYears: number
probationMonths: number
probationSalary: number
renewalCount: number
attachmentName: string | null
attachmentUrl: string | null
electronicContractNo: string | null
electronicContractUrl: string | null
}
export interface RiskItem {
id: string
orgId: string
employeeId: string | null
type: 'CONTRACT' | 'SALARY' | 'TERMINATION'
level: 'HIGH' | 'MEDIUM' | 'LOW'
status: 'PENDING' | 'RESOLVED' | 'IGNORED'
title: string
description: string
actionUrl: string | null
createdAt: string
}
export interface DashboardData {
greeting: string
stats: {
employeeCount: number
highRiskCount: number
todoCount: number
monthlyOvertimePay: number
}
todos: {
id: string
level: 'high' | 'medium' | 'low'
title: string
description: string
actionUrl: string
}[]
resolvedTodos: {
id: string
level: 'high' | 'medium' | 'low'
title: string
description: string
actionUrl: string
resolvedAt: string | null
}[]
riskDistribution: {
contract: number
salary: number
termination: number
}
aiPrediction: {
risks: unknown[]
suggestion: string
} | null
payrollSummary: {
month: string
employeeCount: number
payslipCount: number
confirmedPayslips: number
unconfirmedPayslips: number
baseSalary: number
overtimePay: number
allowance: number
deduction: number
totalPay: number
socialOrg: number
socialEmp: number
housingOrg: number
housingEmp: number
estimatedTax: number
severancePay: number
orgTotalCost: number
empNetPay: number
}
monthlyActivities: {
month: string
newContracts: number
terminations: number
disciplinaryActions: number
attendanceRecords: number
overtimeHours: number
overtimePay: number
}
}
export interface TerminationRecord {
id: string
employeeId: string
employeeName: string
reason: 'NEGOTIATED' | 'FAULT' | 'NONFAULT' | 'LAYOFF' | 'EXPIRED'
terminationDate: string
compensation: number
riskLevel: 'SAFE' | 'WARNING' | 'DANGER'
checklist: { item: string; passed: boolean; remark?: string }[]
createdAt: string
}
export interface Payslip {
id: string
month: string
baseSalary: number
overtimePay: number
weekdayOvertimePay: number
weekendOvertimePay: number
holidayOvertimePay: number
totalPay: number
confirmedAt: string | null
}
+23
View File
@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2563EB',
light: '#3B82F6',
dark: '#1D4ED8',
},
danger: '#DC2626',
warning: '#F59E0B',
safe: '#16A34A',
surface: '#F8FAFC',
},
maxWidth: {
content: '960px',
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})