feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header - 侧边栏菜单分组间距增大,分组间分隔线 - 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计 - 修复Policies.tsx民主程序推进bug(字段名/API路径/参数) - 用工文本模板变量名英文转中文显示 - 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY) - 通知示例数据补充 - h2标题统一为text-sm font-medium - 新增run.md
This commit is contained in:
+35
-10
@@ -1,8 +1,9 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import TopNav from './components/layout/TopNav'
|
||||
import SidebarNav from './components/layout/SidebarNav'
|
||||
import MobileTabBar from './components/layout/MobileTabBar'
|
||||
import PageContainer from './components/layout/PageContainer'
|
||||
import { SkeletonPage } from './components/ui/Skeleton'
|
||||
@@ -18,11 +19,21 @@ const Roster = lazy(() => import('./pages/Roster'))
|
||||
const Termination = lazy(() => import('./pages/Termination'))
|
||||
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Evidence = lazy(() => import('./pages/Evidence'))
|
||||
const Policies = lazy(() => import('./pages/Policies'))
|
||||
const Attendance = lazy(() => import('./pages/Attendance'))
|
||||
const Templates = lazy(() => import('./pages/Templates'))
|
||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||
const Notifications = lazy(() => import('./pages/Notifications'))
|
||||
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
|
||||
const Payslip = lazy(() => import('./pages/portal/Payslip'))
|
||||
const MyContract = lazy(() => import('./pages/portal/MyContract'))
|
||||
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
|
||||
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
|
||||
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
|
||||
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
|
||||
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
|
||||
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
@@ -37,16 +48,20 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<TopNav />
|
||||
<main className="flex-1 py-6 pb-20 md:pb-6">
|
||||
<PageContainer>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</PageContainer>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
<OnboardingGuide />
|
||||
<div className="flex min-h-screen bg-[#f8f9fb]">
|
||||
<SidebarNav mobileOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<TopNav onMenuClick={() => setSidebarOpen(true)} />
|
||||
<main className="flex-1 py-6 pb-20 md:pb-6">
|
||||
<PageContainer>
|
||||
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
|
||||
</PageContainer>
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
<OnboardingGuide />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -78,6 +93,15 @@ export default function App() {
|
||||
<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="/evidence" element={<ProtectedRoute><AdminLayout><Evidence /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/policies" element={<ProtectedRoute><AdminLayout><Policies /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/attendance" element={<ProtectedRoute><AdminLayout><Attendance /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><AdminLayout><Templates /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/audit" element={<ProtectedRoute><AdminLayout><AuditLog /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/notifications" element={<ProtectedRoute><AdminLayout><Notifications /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
{/* 员工端 */}
|
||||
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
|
||||
@@ -85,6 +109,7 @@ export default function App() {
|
||||
<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="/portal/policies" element={<PortalLayout><MyPolicies /></PortalLayout>} />
|
||||
|
||||
{/* 兜底 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 面包屑导航组件
|
||||
* 根据当前路由自动生成分组 > 页面 的层级面包屑
|
||||
*/
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronRight, Home } from 'lucide-react'
|
||||
|
||||
interface BreadcrumbItem {
|
||||
group: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||
'/': { group: '工作台', label: '总览' },
|
||||
'/roster': { group: '员工管理', label: '花名册' },
|
||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
||||
'/social': { group: '薪税社保', label: '社保公积金' },
|
||||
'/evidence': { group: '合规风控', label: '证据链' },
|
||||
'/policies': { group: '合规风控', label: '规章制度' },
|
||||
'/tools/health-check': { group: '合规风控', label: '用工体检' },
|
||||
'/tools/medical-period': { group: '合规风控', label: '医疗期计算' },
|
||||
'/tools/annual-value': { group: '合规风控', label: '年度价值' },
|
||||
'/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' },
|
||||
'/templates': { group: 'AI 辅助', label: '文本模板' },
|
||||
'/notifications': { group: '系统', label: '通知管理' },
|
||||
'/audit': { group: '系统', label: '操作日志' },
|
||||
'/settings': { group: '系统', label: '设置' },
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const path = location.pathname
|
||||
const item = ROUTE_MAP[path]
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-xs text-gray-400" aria-label="面包屑导航">
|
||||
<Link to="/" className="flex items-center gap-1 hover:text-gray-600 transition-colors">
|
||||
<Home className="w-3 h-3" />
|
||||
<span>首页</span>
|
||||
</Link>
|
||||
{item.group !== '工作台' && (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-gray-400">{item.group}</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<span className="text-gray-700 font-medium">{item.label}</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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)}>
|
||||
<div className={clsx('w-full px-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 侧边栏导航组件
|
||||
* 分组式菜单结构,支持折叠/展开,移动端抽屉模式
|
||||
*/
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
LayoutDashboard, Users, CalendarCheck, UserX,
|
||||
Calculator, Shield,
|
||||
FileSearch, FileText, Stethoscope, HeartPulse, Award,
|
||||
Bot, BookMarked,
|
||||
Bell, ScrollText, Settings,
|
||||
ChevronDown, ChevronRight,
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: '工作台',
|
||||
items: [
|
||||
{ path: '/', label: '总览', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '员工管理',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/attendance', label: '考勤确认', icon: CalendarCheck },
|
||||
{ path: '/termination', label: '解聘补偿', icon: UserX },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '薪税社保',
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '合规风控',
|
||||
items: [
|
||||
{ path: '/evidence', label: '证据链', icon: FileSearch },
|
||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
||||
{ path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse },
|
||||
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI 辅助',
|
||||
items: [
|
||||
{ path: '/ai-assistant', label: 'AI 顾问', icon: Bot },
|
||||
{ path: '/templates', label: '文本模板', icon: BookMarked },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统',
|
||||
items: [
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/audit', label: '操作日志', icon: ScrollText },
|
||||
{ path: '/settings', label: '设置', icon: Settings },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
|
||||
const location = useLocation()
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(navGroups.map(g => g.title)))
|
||||
|
||||
const toggleGroup = (title: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(title)) next.delete(title)
|
||||
else next.add(title)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/'
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 移动端遮罩 */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
aria-label="关闭侧边栏"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed md:sticky top-0 left-0 z-50 md:z-auto',
|
||||
'w-52 h-screen md:h-screen flex-shrink-0',
|
||||
'bg-white border-r border-gray-200',
|
||||
'flex flex-col',
|
||||
'transition-transform duration-200',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Logo 区 */}
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
|
||||
<Building2 className="w-5 h-5 text-primary" />
|
||||
<span className="font-bold text-sm text-gray-900">用工合规助手</span>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-3">
|
||||
{navGroups.map((group, idx) => {
|
||||
const isExpanded = expandedGroups.has(group.title)
|
||||
const hasActiveItem = group.items.some(item => isActive(item.path))
|
||||
|
||||
return (
|
||||
<div key={group.title} className={idx > 0 ? 'pt-2 border-t border-gray-100' : ''}>
|
||||
{/* 分组标题 */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.title)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full px-2 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||||
hasActiveItem ? 'text-gray-800' : 'text-gray-500 hover:text-gray-700',
|
||||
)}
|
||||
>
|
||||
<span>{group.title}</span>
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{/* 菜单项 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.path)
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-800',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, Settings as SettingsIcon, Bell, Menu } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import clsx from 'clsx'
|
||||
import Breadcrumb from './Breadcrumb'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: '总览' },
|
||||
{ path: '/roster', label: '员工管理' },
|
||||
{ path: '/money', label: '薪税' },
|
||||
{ path: '/social', label: '社保公积金' },
|
||||
{ path: '/termination', label: '解聘补偿' },
|
||||
{ path: '/ai-assistant', label: 'AI顾问' },
|
||||
]
|
||||
|
||||
export default function TopNav() {
|
||||
const location = useLocation()
|
||||
export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
@@ -32,47 +22,33 @@ export default function TopNav() {
|
||||
const riskCount = dashboardData?.riskSummary?.pending || 0
|
||||
|
||||
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 === '/' && riskCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
|
||||
{riskCount > 99 ? '99+' : riskCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-gray-200">
|
||||
<div className="h-14 flex items-center justify-between px-4">
|
||||
{/* 左侧:hamburger(移动端)+ 面包屑 */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-1.5 rounded-md hover:bg-gray-100"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
|
||||
{/* 右侧:通知 + 设置 + 用户菜单 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
|
||||
<SettingsIcon className="w-4 h-4 text-gray-600" />
|
||||
</Link>
|
||||
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
|
||||
<Link to="/notifications" className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
|
||||
<Bell className="w-4 h-4 text-gray-600" />
|
||||
{riskCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
|
||||
{riskCount > 99 ? '99+' : riskCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 错误提示横幅组件
|
||||
* 统一各页面的错误信息展示样式
|
||||
*/
|
||||
|
||||
interface ErrorBannerProps {
|
||||
/** 错误信息 */
|
||||
message: string
|
||||
/** 变体:错误(红) / 警告(黄) */
|
||||
variant?: 'error' | 'warning'
|
||||
/** 自定义 className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
error: 'bg-red-50 text-red-700',
|
||||
warning: 'bg-amber-50 text-amber-700',
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误/警告提示横幅
|
||||
*/
|
||||
export default function ErrorBanner({ message, variant = 'error', className = '' }: ErrorBannerProps) {
|
||||
return (
|
||||
<div className={`px-3 py-2 rounded-md text-xs ${variantStyles[variant]} ${className}`}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 加载中组件
|
||||
* 统一各页面的"加载中..."显示
|
||||
*/
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
/** 自定义文本 */
|
||||
text?: string
|
||||
/** 容器 padding */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载中占位
|
||||
*/
|
||||
export default function LoadingSpinner({ text = '加载中...', className = 'py-16' }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className={`text-center text-sm text-gray-400 ${className}`}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 状态徽章组件
|
||||
* 统一各页面的状态标签样式,替代重复的 `px-2 py-0.5 rounded text-xs` 模式
|
||||
*/
|
||||
|
||||
interface StatusBadgeProps {
|
||||
/** 状态文本 */
|
||||
label: string
|
||||
/** 颜色变体 */
|
||||
variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'purple'
|
||||
/** 自定义 className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variantStyles: Record<string, string> = {
|
||||
success: 'bg-green-50 text-safe',
|
||||
warning: 'bg-yellow-50 text-yellow-700',
|
||||
danger: 'bg-red-50 text-danger',
|
||||
info: 'bg-blue-50 text-blue-600',
|
||||
neutral: 'bg-gray-100 text-gray-500',
|
||||
purple: 'bg-purple-50 text-purple-700',
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态徽章
|
||||
*/
|
||||
export default function StatusBadge({ label, variant = 'neutral', className = '' }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${variantStyles[variant]} ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** UI 组件统一导出 */
|
||||
export { default as Button } from './Button'
|
||||
export { default as Card } from './Card'
|
||||
export { default as Modal } from './Modal'
|
||||
export { default as Pagination } from './Pagination'
|
||||
export { default as EmptyState } from './EmptyState'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||
export { Skeleton, SkeletonText, SkeletonCard, SkeletonPage } from './Skeleton'
|
||||
export { default as Signal } from './Signal'
|
||||
export { default as StatusBadge } from './StatusBadge'
|
||||
export { default as LoadingSpinner } from './LoadingSpinner'
|
||||
export { default as ErrorBanner } from './ErrorBanner'
|
||||
export { Input, Label, Select } from './Input'
|
||||
@@ -556,7 +556,7 @@ function PredictTab() {
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">AI 风险预测</h2>
|
||||
<h2 className="text-sm font-medium">AI 风险预测</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
@@ -713,7 +713,7 @@ function ReviewTab() {
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileSearch className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">合同审查</h2>
|
||||
<h2 className="text-sm font-medium">合同审查</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
@@ -908,7 +908,7 @@ function CaseTab() {
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Scale className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-medium">案例匹配</h2>
|
||||
<h2 className="text-sm font-medium">案例匹配</h2>
|
||||
<Button size="sm" variant="secondary" className="ml-auto" onClick={() => setShowHistory(!showHistory)}><History className="w-4 h-4 mr-1" />历史记录</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { CalendarCheck, CheckCircle, Clock, AlertCircle, Upload } 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 STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: typeof CheckCircle }> = {
|
||||
PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock },
|
||||
CONFIRMED: { label: '已确认', color: 'text-green-700', bg: 'bg-green-100', icon: CheckCircle },
|
||||
DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle },
|
||||
}
|
||||
|
||||
/**
|
||||
* 考勤确认管理页面
|
||||
*/
|
||||
export default function Attendance() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['attendance', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/attendance?month=${month}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery<any>({
|
||||
queryKey: ['attendance-stats', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/attendance/stats?month=${month}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">考勤确认</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">月度考勤数据确认与异议管理</p>
|
||||
</div>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={e => setMonth(e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '总计', value: stats.total, color: 'text-gray-700' },
|
||||
{ label: '待确认', value: stats.pending, color: 'text-amber-600' },
|
||||
{ label: '已确认', value: stats.confirmed, color: 'text-green-600' },
|
||||
{ label: '有异议', value: stats.disputed, color: 'text-red-600' },
|
||||
].map(s => (
|
||||
<Card key={s.label} className="text-center">
|
||||
<div className={`text-lg font-bold ${s.color}`}>{s.value}</div>
|
||||
<div className="text-xs text-gray-500">{s.label}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="本月暂无考勤确认记录" description="请先批量导入考勤数据" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((item: any) => {
|
||||
const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING
|
||||
const StatusIcon = config.icon
|
||||
return (
|
||||
<Card key={item.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gray-50 flex-shrink-0">
|
||||
<CalendarCheck className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{item.employee?.name}</span>
|
||||
<span className="text-xs text-gray-500">{item.employee?.department}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
||||
<span>出勤 {item.workDays} 天</span>
|
||||
<span>工作日加班 {item.weekdayHours}h</span>
|
||||
<span>周末加班 {item.weekendHours}h</span>
|
||||
<span>法定加班 {item.holidayHours}h</span>
|
||||
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
||||
</div>
|
||||
{item.disputeNote && (
|
||||
<div className="text-xs text-red-600 mt-1">异议说明:{item.disputeNote}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ScrollText, Search, Filter } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
CREATE: '创建',
|
||||
UPDATE: '更新',
|
||||
DELETE: '删除',
|
||||
CORRECT: '更正',
|
||||
CREATE_EMPLOYEE: '创建员工',
|
||||
UPDATE_EMPLOYEE: '更新员工',
|
||||
DELETE_EMPLOYEE: '删除员工',
|
||||
IMPORT_EMPLOYEES: '导入员工',
|
||||
BATCH_RENEW: '批量续签',
|
||||
ADD_CONTRACT: '添加合同',
|
||||
CREATE_CONTRACT: '创建合同',
|
||||
SIGN_CONTRACT: '签署合同',
|
||||
TERMINATE: '解聘',
|
||||
CREATE_TERMINATION: '创建解聘记录',
|
||||
APPROVE_TERMINATION: '审批解聘',
|
||||
REJECT_TERMINATION: '驳回解聘',
|
||||
EXECUTE_TERMINATION: '执行解聘',
|
||||
CANCEL_TERMINATION: '撤销解聘',
|
||||
SUBMIT_TERMINATION: '提交解聘审批',
|
||||
CREATE_DRAFT: '创建草稿',
|
||||
REHIRE: '重新入职',
|
||||
PAYROLL_GENERATE: '生成工资条',
|
||||
PAYROLL_ARCHIVE: '归档发薪批次',
|
||||
PAYROLL_PUBLISH: '发布工资条',
|
||||
EXPORT_PAYROLL: '导出薪税',
|
||||
AI_CHAT: 'AI 对话',
|
||||
AI_REVIEW: 'AI 审查',
|
||||
UPDATE_SETTINGS: '更新设置',
|
||||
POLICY_ADVANCE: '制度流程推进',
|
||||
POLICY_PUBLISH: '制度发布',
|
||||
EVIDENCE_CREATE: '创建证据链',
|
||||
ATTENDANCE_CONFIRM: '考勤确认',
|
||||
}
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
EMPLOYEE: '员工',
|
||||
CONTRACT: '合同',
|
||||
TERMINATION: '解聘',
|
||||
TERMINATION_RECORD: '解聘记录',
|
||||
PAYROLL: '薪税',
|
||||
PAYSLIP: '工资条',
|
||||
SETTINGS: '设置',
|
||||
POLICY: '制度',
|
||||
POLICY_DOCUMENT: '制度文件',
|
||||
EVIDENCE: '证据链',
|
||||
AI: 'AI',
|
||||
ATTENDANCE: '考勤',
|
||||
DISCIPLINARY: '违纪',
|
||||
EmployeeSocialInsRecord: '社保记录',
|
||||
EmployeeHousingFundRecord: '公积金记录',
|
||||
SocialMonthlyProcess: '社保月度流程',
|
||||
RetirementPolicy: '退休政策',
|
||||
NotificationSetting: '通知设置',
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统操作日志页面
|
||||
*/
|
||||
export default function AuditLog() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [action, setAction] = useState('')
|
||||
const [entity, setEntity] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['audit-logs', page, pageSize, action, entity, dateFrom, dateTo],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (action) params.set('action', action)
|
||||
if (entity) params.set('entity', entity)
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
const res = await api.get(`/audit?${params}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery<any>({
|
||||
queryKey: ['audit-stats'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/audit/stats') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">系统操作日志</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">记录所有关键操作,保留6个月以上</p>
|
||||
|
||||
{/* 统计概览 */}
|
||||
{stats && stats.length > 0 && (
|
||||
<Card>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">操作类型分布</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.slice(0, 10).map((s: any) => (
|
||||
<div key={s.action} className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gray-50 text-xs">
|
||||
<span className="text-gray-600">{ACTION_LABELS[s.action] || s.action}</span>
|
||||
<span className="font-bold text-primary">{s._count.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">操作类型</label>
|
||||
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
|
||||
<option value="">全部</option>
|
||||
{Object.entries(ACTION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">实体类型</label>
|
||||
<select value={entity} onChange={e => { setEntity(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary">
|
||||
<option value="">全部</option>
|
||||
{Object.entries(ENTITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">开始日期</label>
|
||||
<input type="date" value={dateFrom} onChange={e => { setDateFrom(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">结束日期</label>
|
||||
<input type="date" value={dateTo} onChange={e => { setDateTo(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
{(action || entity || dateFrom || dateTo) && (
|
||||
<Button size="sm" variant="secondary" onClick={() => { setAction(''); setEntity(''); setDateFrom(''); setDateTo(''); setPage(1) }}>清除筛选</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !data?.items || data.items.length === 0 ? (
|
||||
<EmptyState title="暂无操作日志" description="系统操作将自动记录在此" />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="space-y-1.5">
|
||||
{data.items.map((log: any) => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary flex-shrink-0">
|
||||
<ScrollText className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{ACTION_LABELS[log.action] || log.action}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{ENTITY_LABELS[log.entity] || log.entity}</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
{typeof log.detail === 'object' ? JSON.stringify(log.detail).slice(0, 100) : String(log.detail).slice(0, 100)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex-shrink-0 text-right">
|
||||
<div>{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
{log.ip && <div className="text-gray-300">{log.ip}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ function SeveranceCalculator() {
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<h2 className="text-sm font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
@@ -192,7 +192,7 @@ function SeveranceCalculator() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
<h2 className="text-sm font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">离职原因:<span className="text-gray-900">{result.reason}</span></div>
|
||||
@@ -308,7 +308,7 @@ function DoubleSalaryCalculator() {
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">填写信息</h2>
|
||||
<h2 className="text-sm font-medium mb-4">填写信息</h2>
|
||||
<div className="space-y-3">
|
||||
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
|
||||
<div>
|
||||
@@ -336,7 +336,7 @@ function DoubleSalaryCalculator() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||||
<h2 className="text-sm font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" />计算结果</h2>
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">入职日期:<span className="text-gray-900">{hireDate}</span></div>
|
||||
|
||||
@@ -421,7 +421,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{emp.name}</h2>
|
||||
<h2 className="text-sm font-medium">{emp.name}</h2>
|
||||
<span className="text-xs text-gray-500">{emp.department}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight } from 'lucide-react'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, CalendarDays, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -56,6 +56,31 @@ export default function Dashboard() {
|
||||
},
|
||||
})
|
||||
|
||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
||||
const { data: calendarData } = useQuery<any>({
|
||||
queryKey: ['calendar', currentMonth],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: costAnalysis } = useQuery<any>({
|
||||
queryKey: ['cost-analysis', currentMonth],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/cost-analysis?month=${currentMonth}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: complianceScore } = useQuery<any>({
|
||||
queryKey: ['compliance-score'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/compliance-score') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
@@ -175,6 +200,13 @@ export default function Dashboard() {
|
||||
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
|
||||
]
|
||||
|
||||
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
URGENT: { label: '紧急', color: 'text-red-700', bg: 'bg-red-100' },
|
||||
HIGH: { label: '高', color: 'text-orange-700', bg: 'bg-orange-100' },
|
||||
MEDIUM: { label: '中', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||
LOW: { label: '低', color: 'text-gray-600', bg: 'bg-gray-100' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -191,6 +223,33 @@ export default function Dashboard() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 紧急风险横幅 */}
|
||||
{data.urgentRisk && (
|
||||
<Link to={data.urgentRisk.actionUrl}>
|
||||
<Card className="border-red-300 bg-red-50 hover:bg-red-100 transition-colors cursor-pointer">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-200 flex-shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-red-700" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-red-800">紧急风险</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-red-200 text-red-800">预估损失 {fmt(data.urgentRisk.estimatedLoss)}</span>
|
||||
{data.urgentRisk.daysUntilDeadline !== null && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${data.urgentRisk.daysUntilDeadline <= 0 ? 'bg-red-300 text-red-900' : 'bg-red-200 text-red-800'}`}>
|
||||
{data.urgentRisk.daysUntilDeadline <= 0 ? '已逾期' : `${data.urgentRisk.daysUntilDeadline}天后截止`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-red-700 mt-0.5 truncate">{data.urgentRisk.title}</div>
|
||||
<div className="text-xs text-red-600 mt-0.5">{data.urgentRisk.description}</div>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Tab 导航 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => {
|
||||
@@ -218,6 +277,96 @@ export default function Dashboard() {
|
||||
{/* 概览 Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-3">
|
||||
{/* 合规健康度评分 + AI 建议卡片流 */}
|
||||
{complianceScore && (
|
||||
<div className="space-y-3">
|
||||
{/* 评分环 + 5 维度 */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* SVG 环形评分 */}
|
||||
<div className="relative w-28 h-28 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
innerRadius="70%"
|
||||
outerRadius="100%"
|
||||
data={[{ value: complianceScore.overallScore, fill: complianceScore.level === 'safe' ? '#16A34A' : complianceScore.level === 'warning' ? '#D97706' : '#C00000' }]}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar background dataKey="value" cornerRadius={8} />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${complianceScore.level === 'safe' ? 'text-safe' : complianceScore.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore.overallScore}</span>
|
||||
<span className="text-xs text-gray-500">{complianceScore.levelLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 5 维度评分 */}
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{complianceScore.dimensions.map((dim: any) => (
|
||||
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
|
||||
<span className="text-xs text-gray-700">{dim.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{dim.todoCount > 0 && <span className="text-xs text-gray-400">{dim.todoCount}待办</span>}
|
||||
<span className={`text-sm font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* AI 建议卡片流 */}
|
||||
{complianceScore.suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
AI 建议
|
||||
</div>
|
||||
{complianceScore.suggestions.map((s: any, i: number) => {
|
||||
const suggestionConfig: Record<string, { icon: typeof AlertTriangle; bg: string; border: string; iconColor: string; label: string; labelBg: string }> = {
|
||||
danger: { icon: AlertTriangle, bg: 'bg-red-50', border: 'border-red-200', iconColor: 'text-red-600', label: '高风险', labelBg: 'bg-red-100 text-red-700' },
|
||||
warning: { icon: AlertCircle, bg: 'bg-amber-50', border: 'border-amber-200', iconColor: 'text-amber-600', label: '中风险', labelBg: 'bg-amber-100 text-amber-700' },
|
||||
value: { icon: ShieldCheck, bg: 'bg-green-50', border: 'border-green-200', iconColor: 'text-safe', label: '价值', labelBg: 'bg-green-100 text-safe' },
|
||||
knowledge: { icon: BookOpen, bg: 'bg-purple-50', border: 'border-purple-200', iconColor: 'text-purple-600', label: '知识', labelBg: 'bg-purple-100 text-purple-700' },
|
||||
}
|
||||
const config = suggestionConfig[s.type] || { icon: Lightbulb, bg: 'bg-gray-50', border: 'border-gray-200', iconColor: 'text-gray-600', label: '', labelBg: '' }
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<Link key={i} to={s.actionUrl}>
|
||||
<Card className={`${config.bg} ${config.border} border hover:shadow-md transition-shadow cursor-pointer`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.iconColor} bg-white/60 flex-shrink-0`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${config.labelBg}`}>{config.label}</span>
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{s.title}</span>
|
||||
{s.estimatedLoss && s.estimatedLoss > 0 && (
|
||||
<span className="text-xs text-red-600 font-bold">预估损失 {fmt(s.estimatedLoss)}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{s.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-primary flex-shrink-0">
|
||||
{s.actionLabel}
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{stats.map((stat) => {
|
||||
@@ -239,7 +388,7 @@ export default function Dashboard() {
|
||||
{/* 当月人力成本 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" />当月人力成本</h2>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" />当月人力成本</h2>
|
||||
<span className="text-xs text-gray-400">{payroll?.month}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
@@ -259,7 +408,7 @@ export default function Dashboard() {
|
||||
{/* 年度累计人力成本 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />年度累计成本</h2>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />年度累计成本</h2>
|
||||
<span className="text-xs text-gray-400">{yearCost?.year}年</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
@@ -309,7 +458,7 @@ export default function Dashboard() {
|
||||
{/* 本月工作动态 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" />本月工作动态</h2>
|
||||
<span className="text-xs text-gray-500">{activities?.month}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
@@ -328,7 +477,7 @@ export default function Dashboard() {
|
||||
|
||||
{/* 风险分布 */}
|
||||
<Card>
|
||||
<h2 className="font-medium mb-3">风险分布</h2>
|
||||
<h2 className="text-sm font-medium mb-3">风险分布</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-32 h-32 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -407,8 +556,21 @@ export default function Dashboard() {
|
||||
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
|
||||
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-gray-800">{r.title}</div>
|
||||
{r.employeeName && <div className="text-gray-500">{r.employeeName}</div>}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-gray-800">{r.title}</span>
|
||||
{r.priority && priorityConfig[r.priority] && (
|
||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
|
||||
{priorityConfig[r.priority].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{r.employeeName && <span>{r.employeeName}</span>}
|
||||
{r.estimatedLoss > 0 && <span className="text-red-600">损失 {fmt(r.estimatedLoss)}</span>}
|
||||
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
|
||||
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}天`}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="w-3 h-3 text-gray-500" />
|
||||
</Link>
|
||||
@@ -420,6 +582,84 @@ export default function Dashboard() {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* HR 月度日历 + 人力成本分析 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* 月度日历 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-primary" />本月关键日期</h2>
|
||||
<span className="text-xs text-gray-400">{currentMonth}</span>
|
||||
</div>
|
||||
{calendarData?.events && calendarData.events.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-64 overflow-y-auto">
|
||||
{calendarData.events.slice(0, 10).map((ev: any, i: number) => (
|
||||
<Link key={i} to={ev.actionUrl || '/'} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
|
||||
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
ev.priority === 'high' ? 'bg-red-500' : ev.priority === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
<span className="text-gray-500 w-20 flex-shrink-0">{ev.date.slice(5)}</span>
|
||||
<span className="text-gray-800 truncate flex-1">{ev.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
{calendarData.events.length > 10 && (
|
||||
<div className="text-xs text-gray-500 text-center pt-1">还有 {calendarData.events.length - 10} 个事件</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-4">本月暂无关键日期</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 人力成本分析 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" />人力成本分析</h2>
|
||||
<span className="text-xs text-gray-400">{currentMonth}</span>
|
||||
</div>
|
||||
{costAnalysis ? (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
|
||||
<div className="text-xs text-gray-500">环比上月</div>
|
||||
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
|
||||
<div className="text-xs text-gray-500">同比去年</div>
|
||||
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-2">
|
||||
<span className="text-xs text-gray-600">本月人均成本</span>
|
||||
<span className="text-sm font-bold text-primary">{fmt(costAnalysis.current?.perCapita || 0)}</span>
|
||||
</div>
|
||||
{costAnalysis.factors && costAnalysis.factors.length > 0 && (
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<div className="text-xs font-medium text-gray-600">成本变化归因</div>
|
||||
{costAnalysis.factors.map((f: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs">
|
||||
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${f.impact >= 0 ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<span className="text-gray-600 flex-1">{f.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-4">暂无成本分析数据</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -427,7 +667,7 @@ export default function Dashboard() {
|
||||
{activeTab === 'payroll' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" />本月薪税费用总览</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
|
||||
<Download className="w-4 h-4 mr-1" />导出
|
||||
@@ -533,7 +773,7 @@ export default function Dashboard() {
|
||||
{/* 待办列表 */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
|
||||
<span className="text-xs text-gray-500">{filteredTodos.length} 项</span>
|
||||
</div>
|
||||
|
||||
@@ -588,7 +828,20 @@ export default function Dashboard() {
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-800">{todo.title}</span>
|
||||
{todo.priority && priorityConfig[todo.priority] && (
|
||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||
{priorityConfig[todo.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{todo.estimatedLoss > 0 && (
|
||||
<span className="text-xs text-red-600 font-medium">损失 {fmt(todo.estimatedLoss)}</span>
|
||||
)}
|
||||
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
|
||||
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -622,7 +875,7 @@ export default function Dashboard() {
|
||||
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" />已办事项</h2>
|
||||
<span className="text-xs text-gray-500">{data.resolvedTodos.length} 项</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ShieldCheck, FileText, AlertCircle, CheckCircle, XCircle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
|
||||
/**
|
||||
* 证据链管理页面
|
||||
*/
|
||||
export default function Evidence() {
|
||||
const [refType, setRefType] = useState<string>('')
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['evidence', refType],
|
||||
queryFn: async () => {
|
||||
const params = refType ? `?category=${refType}` : '?category=ALL'
|
||||
const res = await api.get(`/evidence${params}`) as any
|
||||
return res.data?.records || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: verifyResult, refetch: verifyAll } = useQuery<any>({
|
||||
queryKey: ['evidence-verify-all'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/evidence/verify-all') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">证据链管理</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">不可篡改的操作证据链,用于劳动仲裁举证</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => verifyAll()}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-primary text-white hover:bg-primary/90"
|
||||
>
|
||||
验证全部完整性
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{verifyResult && (
|
||||
<Card className={verifyResult?.invalid === 0 ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}>
|
||||
<div className="flex items-center gap-2">
|
||||
{verifyResult?.invalid === 0 ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{verifyResult?.invalid === 0
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
|
||||
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['', 'ONBOARD', 'CONTRACT_SIGN', 'PAYSLIP_CONFIRM', 'DISCIPLINARY', 'ATTENDANCE', 'TERMINATION'].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setRefType(t)}
|
||||
className={`px-3 py-1 text-xs rounded-lg ${refType === t ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{t === '' ? '全部' : t === 'DISCIPLINARY' ? '违纪' : t === 'TERMINATION' ? '解聘' : t === 'CONTRACT_SIGN' ? '合同' : t === 'PAYSLIP_CONFIRM' ? '薪酬' : t === 'ONBOARD' ? '入职' : '考勤'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无证据链记录" description="系统操作将自动生成证据链" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((item: any) => (
|
||||
<Card key={item.id} className="hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2.5 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{item.lastAction || item.firstAction || item.category}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">
|
||||
{item.category === 'DISCIPLINARY' ? '违纪' : item.category === 'TERMINATION' ? '解聘' : item.category === 'CONTRACT_SIGN' ? '合同' : item.category === 'PAYSLIP_CONFIRM' ? '薪酬' : item.category === 'ONBOARD' ? '入职' : '考勤'}
|
||||
</span>
|
||||
</div>
|
||||
{item.employeeName && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
员工:{item.employeeName}{item.employeeDept ? ` · ${item.employeeDept}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 mt-0.5 flex items-center gap-2">
|
||||
<span>{item.eventCount} 条事件 · {new Date(item.createdAt).toLocaleString('zh-CN')}</span>
|
||||
{item.hashShort && (
|
||||
<span className="font-mono text-gray-400">SHA: {item.hashShort}…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: '合同到期',
|
||||
CONTRACT_UNSIGNED: '未签合同',
|
||||
OVERTIME: '加班预警',
|
||||
OVERTIME_ALERT: '加班预警',
|
||||
PAYSLIP: '工资条',
|
||||
PAYSLIP_READY: '工资条',
|
||||
RISK_ALERT: '风险预警',
|
||||
SOCIAL_INS: '社保提醒',
|
||||
HOUSING_FUND: '公积金提醒',
|
||||
TAX: '税务提醒',
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
WECHAT: '企业微信',
|
||||
EMAIL: '邮件',
|
||||
IN_APP: '站内',
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知管理页面
|
||||
*/
|
||||
export default function Notifications() {
|
||||
const queryClient = useQueryClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['notification-logs', page, pageSize],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/notifications/logs?page=${page}&pageSize=${pageSize}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: settings } = useQuery<any>({
|
||||
queryKey: ['notification-settings'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/notifications/settings') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const checkContractsMutation = useMutation({
|
||||
mutationFn: () => api.post('/notifications/check-contracts'),
|
||||
onSuccess: () => {
|
||||
toast.success('合同到期检查已触发')
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-logs'] })
|
||||
},
|
||||
onError: () => toast.error('检查失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">通知管理</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">通知记录查看与设置管理</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => checkContractsMutation.mutate()} disabled={checkContractsMutation.isPending}>
|
||||
<Send className="w-4 h-4 mr-1" />检查合同到期
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowSettings(true)}>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />通知设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通知设置概览 */}
|
||||
{settings && (
|
||||
<Card>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '合同到期提醒', enabled: settings.contractExpiry, days: settings.expiryDays },
|
||||
{ label: '未签合同提醒', enabled: settings.contractUnsigned },
|
||||
{ label: '加班预警', enabled: settings.overtimeAlert },
|
||||
{ label: '工资条通知', enabled: settings.payslipReady },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
|
||||
{s.enabled ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium truncate">{s.label}</div>
|
||||
{s.days && <div className="text-xs text-gray-500">提前{s.days}天</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 通知记录列表 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !data?.items || data.items.length === 0 ? (
|
||||
<EmptyState title="暂无通知记录" description="系统将自动发送合同到期、工资条等通知" />
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="space-y-1.5">
|
||||
{data.items.map((log: any) => (
|
||||
<div key={log.id} className="flex items-start gap-3 px-2 py-2 rounded-md hover:bg-gray-50 transition-colors">
|
||||
<div className={`flex items-center justify-center w-7 h-7 rounded-lg flex-shrink-0 ${
|
||||
log.status === 'SENT' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
|
||||
}`}>
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{log.title}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{TYPE_LABELS[log.type] || log.type}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-600">{CHANNEL_LABELS[log.channel] || log.channel}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{log.content}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 flex-shrink-0">
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 通知设置弹窗 */}
|
||||
{showSettings && settings && (
|
||||
<SettingsModal settings={settings} onClose={() => setShowSettings(false)} onSuccess={() => { setShowSettings(false); queryClient.invalidateQueries({ queryKey: ['notification-settings'] }) }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
contractExpiry: settings.contractExpiry ?? true,
|
||||
expiryDays: settings.expiryDays ?? 30,
|
||||
contractUnsigned: settings.contractUnsigned ?? true,
|
||||
overtimeAlert: settings.overtimeAlert ?? true,
|
||||
payslipReady: settings.payslipReady ?? true,
|
||||
payrollDay: settings.payrollDay ?? 10,
|
||||
socialInsDay: settings.socialInsDay ?? 15,
|
||||
housingFundDay: settings.housingFundDay ?? 15,
|
||||
taxDay: settings.taxDay ?? 15,
|
||||
wechatWebhook: settings.wechatWebhook ?? '',
|
||||
emailNotify: settings.emailNotify ?? false,
|
||||
email: settings.email ?? '',
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.put('/notifications/settings', form),
|
||||
onSuccess: () => { toast.success('通知设置已保存'); onSuccess() },
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
const toggleItem = (key: string) => setForm(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="max-w-lg w-full max-h-[80vh] overflow-y-auto">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">通知设置</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'contractExpiry', label: '合同到期提醒', desc: '提前提醒即将到期的合同' },
|
||||
{ key: 'contractUnsigned', label: '未签合同提醒', desc: '入职超过30天未签合同' },
|
||||
{ key: 'overtimeAlert', label: '加班预警', desc: '加班时长超过法定上限' },
|
||||
{ key: 'payslipReady', label: '工资条通知', desc: '工资条生成后通知员工' },
|
||||
{ key: 'emailNotify', label: '邮件通知', desc: '通过邮件发送通知' },
|
||||
].map(item => (
|
||||
<div key={item.key} className="flex items-center justify-between p-2 rounded-lg bg-gray-50">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleItem(item.key)}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${form[item.key as keyof typeof form] ? 'bg-primary' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${form[item.key as keyof typeof form] ? 'left-5' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">合同提前提醒天数</label>
|
||||
<input type="number" value={form.expiryDays} onChange={e => setForm(prev => ({ ...prev, expiryDays: parseInt(e.target.value) || 30 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={365} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">发薪日</label>
|
||||
<input type="number" value={form.payrollDay} onChange={e => setForm(prev => ({ ...prev, payrollDay: parseInt(e.target.value) || 10 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={28} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">企业微信 Webhook URL</label>
|
||||
<input type="url" value={form.wechatWebhook} onChange={e => setForm(prev => ({ ...prev, wechatWebhook: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">通知邮箱</label>
|
||||
<input type="email" value={form.email} onChange={e => setForm(prev => ({ ...prev, email: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="hr@example.com" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } 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 STEP_LABELS: Record<string, string> = {
|
||||
DRAFTING: '起草',
|
||||
DISCUSSION: '讨论',
|
||||
CONSULTATION: '协商',
|
||||
PUBLICATION: '公示',
|
||||
}
|
||||
|
||||
const STEP_ORDER = ['DRAFTING', 'DISCUSSION', 'CONSULTATION', 'PUBLICATION']
|
||||
|
||||
/**
|
||||
* 规章制度民主程序管理页面
|
||||
*/
|
||||
export default function Policies() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['policies'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/policies') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const advanceMutation = useMutation({
|
||||
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }),
|
||||
onSuccess: () => {
|
||||
toast.success('流程步骤已推进')
|
||||
queryClient.invalidateQueries({ queryKey: ['policies'] })
|
||||
// 刷新选中制度详情
|
||||
if (selectedPolicy) {
|
||||
api.get(`/policies/${selectedPolicy.id}`).then((res: any) => setSelectedPolicy(res.data))
|
||||
}
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">规章制度管理</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">民主程序四步法:起草 → 讨论 → 协商 → 公示</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建制度
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((p: any) => (
|
||||
<Card key={p.id} className="hover:shadow-md transition-shadow cursor-pointer" >
|
||||
<div onClick={() => setSelectedPolicy(p)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{p.title}</span>
|
||||
{p.status === 'PUBLISHED' ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">已生效</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">{STEP_LABELS[STEP_ORDER[(p.democracyProgress?.currentStep || 1) - 1]] || '起草中'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1 truncate">{p.content?.slice(0, 80) || '暂无内容'}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{STEP_ORDER.map((step, i) => {
|
||||
const currentStep = p.democracyProgress?.currentStep || 1
|
||||
const isDone = i < currentStep - 1 || p.status === 'PUBLISHED'
|
||||
const isCurrent = i === currentStep - 1 && p.status !== 'PUBLISHED'
|
||||
return (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
|
||||
isDone ? 'bg-green-100 text-green-700' : isCurrent ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{isDone ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||
{STEP_LABELS[step]}
|
||||
</div>
|
||||
{i < STEP_ORDER.length - 1 && <ChevronRight className="w-3 h-3 text-gray-300" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{p.status === 'PUBLISHED' && p.totalEmployees > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2 text-xs">
|
||||
<span className="text-gray-500">员工签收:</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-1.5 overflow-hidden max-w-32">
|
||||
<div className="bg-green-500 h-full rounded-full" style={{ width: `${p.totalEmployees > 0 ? Math.round((p.readCount / p.totalEmployees) * 100) : 0}%` }} />
|
||||
</div>
|
||||
<span className="text-gray-600">{p.readCount}/{p.totalEmployees}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
{selectedPolicy && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelectedPolicy(null)}>
|
||||
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto" >
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">{selectedPolicy.title}</h2>
|
||||
<button onClick={() => setSelectedPolicy(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 whitespace-pre-wrap mb-4">{selectedPolicy.content || '暂无内容'}</div>
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="text-xs font-medium text-gray-600">民主程序进度</div>
|
||||
{(selectedPolicy.democracyProgress?.steps || []).map((s: any, i: number) => {
|
||||
const isDone = s.status === 'COMPLETED' || (selectedPolicy.status === 'PUBLISHED')
|
||||
const isCurrent = s.status === 'IN_PROGRESS' && selectedPolicy.status !== 'PUBLISHED'
|
||||
return (
|
||||
<div key={i} className={`p-2 rounded-lg ${isDone ? 'bg-green-50' : isCurrent ? 'bg-primary/5' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm flex items-center gap-1.5">
|
||||
{isDone ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Clock className="w-4 h-4 text-gray-400" />}
|
||||
{s.name}
|
||||
<span className="text-xs text-gray-400 font-normal">· {s.description}</span>
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const note = window.prompt('请输入本步骤备注(可选)') || ''
|
||||
advanceMutation.mutate({ id: selectedPolicy.id, step: i + 1, note })
|
||||
}}
|
||||
>
|
||||
完成本步骤
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{s.date && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{s.status === 'COMPLETED' ? '完成时间' : '开始时间'}:{s.date}
|
||||
{s.note && <span className="ml-2 text-gray-400">备注:{s.note}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 阅读签收统计(仅已公示制度显示) */}
|
||||
{selectedPolicy.status === 'PUBLISHED' && (
|
||||
<ReadStats policyId={selectedPolicy.id} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建弹窗 */}
|
||||
{showCreate && (
|
||||
<CreatePolicyModal onClose={() => setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['policies'] }) }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [type, setType] = useState('RULES')
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => api.post('/policies', { title, content, type }),
|
||||
onSuccess: () => { toast.success('制度已创建'); onSuccess() },
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<Card className="max-w-lg w-full" >
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">新建规章制度</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">制度名称</label>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="如:考勤管理制度" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">分类</label>
|
||||
<select value={type} onChange={e => setType(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<option value="RULES">规章制度</option>
|
||||
<option value="NOTICE">通知公告</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">制度内容</label>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={() => createMutation.mutate()} disabled={!title || !content || createMutation.isPending}>创建</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 阅读签收统计组件
|
||||
*/
|
||||
function ReadStats({ policyId }: { policyId: string }) {
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['policy-read-stats', policyId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/policies/${policyId}/read-stats`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-xs text-gray-400 mt-3">加载阅读统计...</div>
|
||||
if (!data) return null
|
||||
|
||||
const percent = data.total > 0 ? Math.round((data.readCount / data.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">员工阅读签收</div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-green-500 h-full rounded-full transition-all" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 shrink-0">
|
||||
{data.readCount}/{data.total} 人已签收({percent}%)
|
||||
</span>
|
||||
</div>
|
||||
{data.unreadCount > 0 && (
|
||||
<div className="text-xs text-amber-600 mb-2">
|
||||
{data.unreadCount} 人未签收
|
||||
</div>
|
||||
)}
|
||||
{data.records && data.records.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{data.records.map((r: any) => (
|
||||
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-gray-50 text-xs">
|
||||
<span className="text-gray-700">{r.employeeName}</span>
|
||||
<span className="text-gray-400">{r.department}</span>
|
||||
<span className="text-gray-400">{r.readAt?.slice(0, 16).replace('T', ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+5
-3084
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ export default function Settings() {
|
||||
{activeSection === 'import' && <ImportSettings />}
|
||||
{activeSection === 'export' && (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">数据导出</h2>
|
||||
<h2 className="text-sm font-medium mb-4">数据导出</h2>
|
||||
<ExportSettings />
|
||||
</Card>
|
||||
)}
|
||||
@@ -111,7 +111,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">企业信息</h2>
|
||||
<h2 className="text-sm font-medium mb-4">企业信息</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>企业名称</Label>
|
||||
@@ -283,7 +283,7 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">用户管理</h2>
|
||||
<h2 className="text-sm font-medium">用户管理</h2>
|
||||
<Button size="sm" onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加用户
|
||||
</Button>
|
||||
@@ -673,7 +673,7 @@ function NotificationSettings() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<h2 className="font-medium mb-4">通知设置</h2>
|
||||
<h2 className="text-sm font-medium mb-4">通知设置</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-xs">合同到期提醒</span>
|
||||
@@ -749,7 +749,7 @@ function NotificationSettings() {
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">合同到期检查</h2>
|
||||
<h2 className="text-sm font-medium">合同到期检查</h2>
|
||||
<Button size="sm" onClick={() => checkMutation.mutate()} disabled={checkMutation.isPending}>
|
||||
{checkMutation.isPending ? '检查中...' : '立即检查'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { FileText, Copy, X, ChevronRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
CONTRACT: '合同',
|
||||
RULES: '规章制度',
|
||||
NOTICE: '通知',
|
||||
AGREEMENT: '协议',
|
||||
OTHER: '其他',
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, string> = {
|
||||
companyName: '公司名称',
|
||||
employeeName: '员工姓名',
|
||||
idCard: '身份证号',
|
||||
address: '地址',
|
||||
startDate: '开始日期',
|
||||
position: '岗位',
|
||||
endDate: '结束日期',
|
||||
terminationDate: '解除日期',
|
||||
publishDate: '公示日期',
|
||||
effectiveDate: '生效日期',
|
||||
meetingDate: '会议日期',
|
||||
meetingLocation: '会议地点',
|
||||
policyTitle: '制度名称',
|
||||
department: '部门',
|
||||
violationDate: '违纪日期',
|
||||
violationDescription: '违纪描述',
|
||||
probationEndDate: '试用期截止',
|
||||
regularDate: '转正日期',
|
||||
contractEndDate: '合同到期日',
|
||||
salary: '工资',
|
||||
phone: '手机号',
|
||||
hireDate: '入职日期',
|
||||
gender: '性别',
|
||||
birthDate: '出生日期',
|
||||
bankAccount: '银行账号',
|
||||
bankName: '开户银行',
|
||||
emergencyContact: '紧急联系人',
|
||||
emergencyPhone: '紧急联系电话',
|
||||
workYears: '工作年限',
|
||||
compAmount: '补偿金额',
|
||||
noticeDate: '通知日期',
|
||||
reason: '原因',
|
||||
overtimeHours: '加班时长',
|
||||
overtimePay: '加班费',
|
||||
socialInsBase: '社保基数',
|
||||
housingFundBase: '公积金基数',
|
||||
compensation: '经济补偿',
|
||||
lastWorkDay: '最后工作日',
|
||||
socialInsEndMonth: '社保截止月',
|
||||
housingFundEndMonth: '公积金截止月',
|
||||
probationMonths: '试用期月数',
|
||||
monthlySalary: '月薪',
|
||||
workplace: '工作地点',
|
||||
disciplineType: '处分类型',
|
||||
policyBasis: '制度依据',
|
||||
}
|
||||
|
||||
/**
|
||||
* 用工文本模板库页面
|
||||
*/
|
||||
export default function Templates() {
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [selected, setSelected] = useState<any>(null)
|
||||
const [rendered, setRendered] = useState<string>('')
|
||||
const [variables, setVariables] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['templates', category],
|
||||
queryFn: async () => {
|
||||
const params = category ? `?category=${category}` : ''
|
||||
const res = await api.get(`/templates${params}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detail } = useQuery<any>({
|
||||
queryKey: ['template-detail', selected?.id],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/templates/${selected.id}`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!selected,
|
||||
})
|
||||
|
||||
const handleRender = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
const res = await api.post(`/templates/${selected.id}/render`, { variables }) as any
|
||||
setRendered(res.data.content)
|
||||
} catch (err: any) {
|
||||
toast.error('渲染失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(rendered)
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">用工文本模板库</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">合同、制度、通知等常用文本模板,支持变量替换</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{['', 'CONTRACT', 'RULES', 'NOTICE', 'AGREEMENT', 'OTHER'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCategory(c)}
|
||||
className={`px-3 py-1 text-xs rounded-lg ${category === c ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
{c === '' ? '全部' : CATEGORY_LABELS[c]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无模板" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{list.map((t: any) => (
|
||||
<Card key={t.id} className="hover:shadow-md transition-shadow cursor-pointer" >
|
||||
<div onClick={() => { setSelected(t); setRendered(''); setVariables({}) }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-primary/10 text-primary">{CATEGORY_LABELS[t.category]}</span>
|
||||
<span className="text-sm font-medium truncate">{t.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
|
||||
{t.variables?.slice(0, 4).map((v: string) => (
|
||||
<span key={v} className="px-1 py-0.5 rounded bg-gray-100">{VARIABLE_LABELS[v] || v}</span>
|
||||
))}
|
||||
{t.variables?.length > 4 && <span>+{t.variables.length - 4}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模板详情弹窗 */}
|
||||
{selected && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelected(null)}>
|
||||
<Card className="max-w-3xl w-full max-h-[85vh] overflow-y-auto" >
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">{selected.name}</h2>
|
||||
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* 变量输入 */}
|
||||
{detail?.variables && detail.variables.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">填写变量</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{detail.variables.map((v: string) => (
|
||||
<div key={v}>
|
||||
<label className="text-xs text-gray-500">{VARIABLE_LABELS[v] || v}</label>
|
||||
<input
|
||||
value={variables[v] || ''}
|
||||
onChange={e => setVariables(prev => ({ ...prev, [v]: e.target.value }))}
|
||||
className="w-full px-2 py-1 text-sm border rounded focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={`输入${VARIABLE_LABELS[v] || v}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={handleRender}>渲染模板</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染结果 */}
|
||||
{rendered ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">渲染结果</span>
|
||||
<button onClick={handleCopy} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<Copy className="w-3 h-3" />复制
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{rendered}</pre>
|
||||
</div>
|
||||
) : detail?.content ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-2">模板原文</div>
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-[50vh] overflow-y-auto">{detail.content}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1602,7 +1602,7 @@ export default function Termination() {
|
||||
<div className="w-72 shrink-0 hidden md:block">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-medium flex items-center gap-1.5">
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5">
|
||||
<List className="w-4 h-4" />已计算列表
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 员工端 — 规章制度公示页面
|
||||
* 展示已公示制度列表,员工可阅读并签收确认
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
import PortalNav from './PortalNav'
|
||||
|
||||
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 MyPolicies() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
/** 已公示制度列表 */
|
||||
const { data: list, isLoading } = useQuery<any>({
|
||||
queryKey: ['portal-policies'],
|
||||
queryFn: async () => {
|
||||
const res = await portalApi.get('/policies') as any
|
||||
return res.data ?? []
|
||||
},
|
||||
})
|
||||
|
||||
/** 制度详情 */
|
||||
const { data: detail, isLoading: detailLoading } = useQuery<any>({
|
||||
queryKey: ['portal-policy', selectedId],
|
||||
queryFn: async () => {
|
||||
if (!selectedId) return null
|
||||
const res = await portalApi.get(`/policies/${selectedId}`) as any
|
||||
return res.data ?? null
|
||||
},
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
/** 阅读确认 */
|
||||
const readMutation = useMutation({
|
||||
mutationFn: (id: string) => portalApi.post(`/policies/${id}/read`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-policies'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-policy', selectedId] })
|
||||
},
|
||||
})
|
||||
|
||||
if (selectedId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<div className="max-w-md mx-auto py-6 px-4">
|
||||
<PortalNav />
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : detail ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">{detail.title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs text-gray-500">
|
||||
公示时间:{detail.publishedAt?.slice(0, 10) || '-'}
|
||||
</span>
|
||||
{detail.hasRead ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
|
||||
<CheckCircle className="w-3 h-3" />已签收
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3 h-3" />待签收
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed mb-6">
|
||||
{detail.content || '暂无内容'}
|
||||
</div>
|
||||
<div className="border-t pt-4">
|
||||
{detail.hasRead ? (
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
您已于 {detail.readAt?.slice(0, 19).replace('T', ' ')} 签收确认
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => readMutation.mutate(detail.id)}
|
||||
disabled={readMutation.isPending}
|
||||
>
|
||||
{readMutation.isPending ? '确认中...' : '我已阅读并理解,确认签收'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState title="制度不存在" description="该制度可能已被撤回" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
<div className="max-w-md mx-auto py-6 px-4">
|
||||
<PortalNav />
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">规章制度</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : !list || list.length === 0 ? (
|
||||
<EmptyState title="暂无公示制度" description="公司尚未公示任何规章制度" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((p: any) => (
|
||||
<Card key={p.id} className="cursor-pointer hover:shadow-md transition-shadow" >
|
||||
<div onClick={() => setSelectedId(p.id)} className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{p.title}</span>
|
||||
{p.hasRead ? (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700">
|
||||
<CheckCircle className="w-3 h-3" />已签收
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3 h-3" />待签收
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
公示时间:{p.publishedAt?.slice(0, 10) || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 员工端底部导航栏
|
||||
*/
|
||||
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
||||
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||
]
|
||||
|
||||
export default function PortalNav() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('portalToken')
|
||||
localStorage.removeItem('portalEmployee')
|
||||
navigate('/portal/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-1 text-sm ${active ? 'text-primary font-medium' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
export default function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
|
||||
|
||||
const addAttachmentMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/attachments', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||
})
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||
})
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
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: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
|
||||
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xs font-medium">附件管理({attachments?.length || 0}个)</h2>
|
||||
<Card>
|
||||
<div className="flex gap-2 mb-3 flex-nowrap items-center">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
|
||||
<option value="ID_CARD">身份证</option><option value="BANK_CARD">银行卡</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} className="shrink-0 whitespace-nowrap">
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||||
</Button>
|
||||
<span className="text-gray-400 text-xs">支持 PDF/JPG/PNG,最大 10MB</span>
|
||||
</div>
|
||||
{attachments?.length ? (
|
||||
<div className="space-y-2">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2.5 text-xs hover:bg-gray-100">
|
||||
<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 font-medium text-gray-700">{att.fileName}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||||
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||||
<span className="text-gray-400">{new Date(att.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="text-gray-400 text-xs text-center py-4">暂无附件</div>}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
/** 考勤/加班/培训合并组件 */
|
||||
export default function AttendanceOvertimeInfo({ employeeId, attendanceRecords, overtimeRecords, trainingRecords }: { employeeId: string; attendanceRecords: any[]; overtimeRecords: any[]; trainingRecords: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [subTab, setSubTab] = useState<'attendance' | 'overtime' | 'training'>('attendance')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' })
|
||||
const [trainingForm, setTrainingForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||||
})
|
||||
|
||||
const createTrainingMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
})
|
||||
|
||||
const deleteTrainingMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||||
})
|
||||
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
|
||||
const statusMap: Record<string, string> = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
const statusColor: Record<string, string> = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' }
|
||||
|
||||
const totalPay = (overtimeRecords || []).reduce((sum, o) => sum + (o.totalPay || 0), 0)
|
||||
const totalWeekday = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekdayHours || 0), 0)
|
||||
const totalWeekend = (overtimeRecords || []).reduce((sum, o) => sum + (o.weekendHours || 0), 0)
|
||||
const totalHoliday = (overtimeRecords || []).reduce((sum, o) => sum + (o.holidayHours || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSubTab('attendance')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
subTab === 'attendance' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
考勤记录({attendanceRecords?.length || 0}条)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('overtime')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
subTab === 'overtime' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
加班汇总({overtimeRecords?.length || 0}条)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('training')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
subTab === 'training' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
培训签收({trainingRecords?.length || 0}条)
|
||||
</button>
|
||||
</div>
|
||||
{subTab === 'attendance' && (
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增考勤记录</Button>
|
||||
)}
|
||||
{subTab === 'training' && (
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增培训记录</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subTab === 'attendance' && (
|
||||
<>
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>日期</Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
|
||||
<div><Label>考勤状态</Label>
|
||||
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>签到时间</Label><Input type="time" value={form.checkInTime} onChange={(e) => setForm({ ...form, checkInTime: e.target.value })} /></div>
|
||||
<div><Label>签退时间</Label><Input type="time" value={form.checkOutTime} onChange={(e) => setForm({ ...form, checkOutTime: e.target.value })} /></div>
|
||||
<div><Label>迟到(分钟)</Label><Input type="number" value={form.lateMinutes} onChange={(e) => setForm({ ...form, lateMinutes: Number(e.target.value) })} /></div>
|
||||
<div><Label>早退(分钟)</Label><Input type="number" value={form.earlyMinutes} onChange={(e) => setForm({ ...form, earlyMinutes: Number(e.target.value) })} /></div>
|
||||
<div><Label>工时(小时)</Label><Input type="number" step="0.5" value={form.workHours} onChange={(e) => setForm({ ...form, workHours: Number(e.target.value) })} /></div>
|
||||
<div><Label>加班(小时)</Label><Input type="number" step="0.5" value={form.overtimeHours} onChange={(e) => setForm({ ...form, overtimeHours: Number(e.target.value) })} /></div>
|
||||
<div className="md:col-span-2"><Label>备注</Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
|
||||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{attendanceRecords?.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无考勤记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">日期</th>
|
||||
<th className="py-2 text-left">签到</th>
|
||||
<th className="py-2 text-left">签退</th>
|
||||
<th className="py-2 text-center">状态</th>
|
||||
<th className="py-2 text-right">工时</th>
|
||||
<th className="py-2 text-right">加班</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{attendanceRecords?.map((a) => (
|
||||
<tr key={a.id} className="border-b last:border-0">
|
||||
<td className="py-2">{a.date?.toString().slice(0, 10)}</td>
|
||||
<td className="py-2 text-gray-500">{a.checkInTime || '-'}</td>
|
||||
<td className="py-2 text-gray-500">{a.checkOutTime || '-'}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${statusColor[a.status] || 'bg-gray-100'}`}>{statusMap[a.status] || a.status}</span></td>
|
||||
<td className="py-2 text-right">{a.workHours}h</td>
|
||||
<td className="py-2 text-right">{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}</td>
|
||||
<td className="py-2"><button onClick={() => deleteMutation.mutate(a.id)} className="text-xs text-gray-400 hover:text-danger">删除</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{subTab === 'overtime' && (
|
||||
<>
|
||||
{overtimeRecords?.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无加班记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">月份</th>
|
||||
<th className="py-2 text-right">工作日(h)</th>
|
||||
<th className="py-2 text-right">休息日(h)</th>
|
||||
<th className="py-2 text-right">节假日(h)</th>
|
||||
<th className="py-2 text-right">加班费</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overtimeRecords.map((o) => (
|
||||
<tr key={o.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{o.month}</td>
|
||||
<td className="py-2 text-right text-gray-600">{o.weekdayHours || '-'}</td>
|
||||
<td className="py-2 text-right text-gray-600">{o.weekendHours || '-'}</td>
|
||||
<td className="py-2 text-right text-gray-600">{o.holidayHours || '-'}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(o.totalPay)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 bg-gray-50">
|
||||
<td className="py-2 font-medium">合计</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{totalWeekday}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{totalWeekend}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{totalHoliday}</td>
|
||||
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{subTab === 'training' && (
|
||||
<>
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>培训日期</Label><Input type="date" value={trainingForm.trainingDate} onChange={(e) => setTrainingForm({ ...trainingForm, trainingDate: e.target.value })} /></div>
|
||||
<div><Label>培训主题/制度名称</Label><Input value={trainingForm.topic} onChange={(e) => setTrainingForm({ ...trainingForm, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
|
||||
<div className="md:col-span-2"><Label>培训内容摘要</Label><Input value={trainingForm.content} onChange={(e) => setTrainingForm({ ...trainingForm, content: e.target.value })} /></div>
|
||||
<div><Label>培训人</Label><Input value={trainingForm.trainer} onChange={(e) => setTrainingForm({ ...trainingForm, trainer: e.target.value })} /></div>
|
||||
<div><Label>时长(小时)</Label><Input type="number" step="0.5" value={trainingForm.duration} onChange={(e) => setTrainingForm({ ...trainingForm, duration: Number(e.target.value) })} /></div>
|
||||
<div><Label>签收状态</Label>
|
||||
<Select value={trainingForm.ackStatus} onChange={(e) => setTrainingForm({ ...trainingForm, ackStatus: e.target.value })}>
|
||||
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
{trainingForm.ackStatus === 'SIGNED' && <div><Label>签收日期</Label><Input type="date" value={trainingForm.ackDate} onChange={(e) => setTrainingForm({ ...trainingForm, ackDate: e.target.value })} /></div>}
|
||||
<div className="md:col-span-2"><Label>备注</Label><Input value={trainingForm.remark} onChange={(e) => setTrainingForm({ ...trainingForm, remark: e.target.value })} /></div>
|
||||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createTrainingMutation.mutate(trainingForm)} disabled={createTrainingMutation.isPending || !trainingForm.trainingDate || !trainingForm.topic}>{createTrainingMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{trainingRecords?.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无培训签收记录</div></Card>
|
||||
) : trainingRecords?.map((r) => (
|
||||
<Card key={r.id} className="p-4">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-400">{r.trainingDate?.toString().slice(0, 10)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${r.ackStatus === 'SIGNED' ? 'bg-green-50 text-safe' : r.ackStatus === 'REFUSED' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-700">{r.topic}</div>
|
||||
{r.content && <div className="text-xs text-gray-500 leading-relaxed">{r.content}</div>}
|
||||
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
|
||||
<span>时长 {r.duration}h</span>
|
||||
{r.trainer && <span>培训人:{r.trainer}</span>}
|
||||
{r.ackDate && <span className="text-safe">签收日期:{r.ackDate.toString().slice(0, 10)}</span>}
|
||||
{r.remark && <span className="text-gray-400">备注:{r.remark}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => deleteTrainingMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
export default function BasicInfo({ profile, employeeId, attachments }: { profile: any; employeeId: string; attachments: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
|
||||
|
||||
const addAttachmentMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/attachments', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||
})
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||||
})
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('文件过大,请上传小于 10MB 的文件')
|
||||
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: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
|
||||
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
|
||||
const formatSize = (bytes: number) => {
|
||||
if (!bytes) return '-'
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const [form, setForm] = useState({
|
||||
department: profile.department || '',
|
||||
gender: profile.gender || '男',
|
||||
femaleWorkerType: profile.femaleWorkerType || '',
|
||||
phone: profile.phone || '',
|
||||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||||
monthlySalary: profile.monthlySalary || '',
|
||||
emergencyContact: profile.emergencyContact || '',
|
||||
emergencyPhone: profile.emergencyPhone || '',
|
||||
address: profile.address || '',
|
||||
bankName: profile.bankName || '',
|
||||
bankAccount: profile.bankAccount || '',
|
||||
isPregnant: profile.isPregnant || false,
|
||||
isInMedicalPeriod: profile.isInMedicalPeriod || false,
|
||||
isWorkInjured: profile.isWorkInjured || false,
|
||||
socialInsBase: profile.socialInsBase ?? '',
|
||||
housingFundBase: profile.housingFundBase ?? '',
|
||||
specialDeduction: profile.specialDeduction ?? 0,
|
||||
city: profile.city || '',
|
||||
cityChangeReason: '',
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setEditing(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
|
||||
toast.error('参保城市变更必须填写变更原因')
|
||||
return
|
||||
}
|
||||
const data: any = {
|
||||
department: form.department,
|
||||
gender: form.gender,
|
||||
femaleWorkerType: form.femaleWorkerType || undefined,
|
||||
phone: form.phone || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: String(form.monthlySalary),
|
||||
emergencyContact: form.emergencyContact || undefined,
|
||||
emergencyPhone: form.emergencyPhone || undefined,
|
||||
address: form.address || undefined,
|
||||
bankName: form.bankName || undefined,
|
||||
bankAccount: form.bankAccount || undefined,
|
||||
isPregnant: form.isPregnant,
|
||||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||||
isWorkInjured: form.isWorkInjured,
|
||||
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
|
||||
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
|
||||
specialDeduction: Number(form.specialDeduction) || 0,
|
||||
city: form.city || undefined,
|
||||
cityChangeReason: form.city !== profile.city ? form.cityChangeReason || undefined : undefined,
|
||||
}
|
||||
updateMutation.mutate(data)
|
||||
}
|
||||
|
||||
const personalFields = [
|
||||
{ label: '姓名', value: profile.name },
|
||||
{ label: '部门', value: profile.department },
|
||||
{ label: '性别', value: profile.gender || '未填写' },
|
||||
...(profile.gender === '女'
|
||||
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
|
||||
: []),
|
||||
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
|
||||
{ label: '手机号', value: profile.phone || '未填写' },
|
||||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.retirementDaysLeft != null
|
||||
? (() => {
|
||||
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
|
||||
if (profile.birthDate) {
|
||||
const bd = new Date(profile.birthDate)
|
||||
const gender = profile.gender || '男'
|
||||
const fwt = profile.femaleWorkerType || null
|
||||
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
|
||||
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
|
||||
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
|
||||
const baseRetireDate = new Date(bd)
|
||||
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
|
||||
const reformStart = new Date(2025, 0, 1)
|
||||
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
|
||||
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
|
||||
const retireDate = new Date(baseRetireDate)
|
||||
retireDate.setMonth(retireDate.getMonth() + delayMonths)
|
||||
return [{ label: '退休日期', value: `${retireDate.getFullYear()}年${retireDate.getMonth() + 1}月${retireDate.getDate()}日` }]
|
||||
}
|
||||
return [{ label: '距退休', value: `${profile.retirementDaysLeft}天` }]
|
||||
})()
|
||||
: []),
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.reverse()[0] || '未记录' }]
|
||||
: []),
|
||||
]
|
||||
const salaryFields = [
|
||||
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
|
||||
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
|
||||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||||
{ label: '住址', value: profile.address || '未填写' },
|
||||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
]
|
||||
const special = [
|
||||
{ label: '孕期', value: profile.isPregnant },
|
||||
{ label: '医疗期', value: profile.isInMedicalPeriod },
|
||||
{ label: '工伤', value: profile.isWorkInjured },
|
||||
]
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xs font-medium">基本信息</h2>
|
||||
{!editing ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>编辑</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-2">个人信息</h3>
|
||||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
|
||||
{personalFields.map((f) => (
|
||||
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
|
||||
<span className="text-gray-500 shrink-0">{f.label}</span>
|
||||
<span className="font-medium text-right truncate ml-2">{f.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 border-t">
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-2">薪酬与银行</h3>
|
||||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3">
|
||||
{salaryFields.map((f) => (
|
||||
<div key={f.label} className="flex justify-between border-b pb-1.5 text-xs">
|
||||
<span className="text-gray-500 shrink-0">{f.label}</span>
|
||||
<span className="font-medium text-right truncate ml-2">{f.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div><Label>姓名(不可编辑)</Label><Input value={profile.name} disabled /></div>
|
||||
<div><Label>身份证号(不可编辑)</Label><Input value={profile.idCardNumber || ''} disabled /></div>
|
||||
<div><Label>部门</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
|
||||
<div><Label>性别</Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男">男</option><option value="女">女</option></Select></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<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: Number(e.target.value) })} /></div>
|
||||
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
||||
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
||||
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
||||
<div><Label>开户行</Label><Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" /></div>
|
||||
<div><Label>银行账号</Label><Input value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} placeholder="选填" /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 薪税信息 */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-3">薪税信息</h3>
|
||||
{!editing ? (
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="flex justify-between border-b pb-2 text-xs">
|
||||
<span className="text-gray-500">参保城市</span>
|
||||
<span className="font-medium">{profile.city || '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2 text-xs">
|
||||
<span className="text-gray-500">社保缴费基数</span>
|
||||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2 text-xs">
|
||||
<span className="text-gray-500">公积金缴费基数</span>
|
||||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2 text-xs">
|
||||
<span className="text-gray-500">专项附加扣除</span>
|
||||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>参保城市</Label>
|
||||
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>专项附加扣除(元/月)</Label>
|
||||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
{form.city !== (profile.city || '') && (
|
||||
<div className="md:col-span-4">
|
||||
<Label>参保城市变更原因(必填)</Label>
|
||||
<Input placeholder="如:员工从北京调往上海工作" value={form.cityChangeReason} onChange={(e) => setForm({ ...form, cityChangeReason: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||||
</div>
|
||||
|
||||
{/* 特殊状态 */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-3">特殊状态</h3>
|
||||
{!editing ? (
|
||||
<div className="flex gap-4">
|
||||
{special.map((s) => (
|
||||
<span key={s.label} className={`px-3 py-1 rounded text-xs ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
|
||||
{s.label}:{s.value ? '是' : '否'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||||
孕期/哺乳期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||||
医疗期
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||||
工伤
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{(profile.isPregnant || profile.isInMedicalPeriod || profile.isWorkInjured) && !editing && (
|
||||
<p className="text-xs text-amber-600 mt-2">⚠️ 该员工处于特殊保护期,解聘操作将触发法律风险预警</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 员工端二维码 */}
|
||||
{!editing && profile.phone && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-600">员工端入口</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
const url = `${window.location.origin}/portal/login`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white p-3 rounded-lg border">
|
||||
<QRCodeSVG
|
||||
value={`${window.location.origin}/portal/login`}
|
||||
size={120}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>员工扫码进入员工端,使用手机号登录</p>
|
||||
<p>可查看工资条、合同信息、确认签署</p>
|
||||
<p className="text-gray-400">链接:{window.location.origin}/portal/login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 附件管理 */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-600">附件管理({attachments?.length || 0}个)</h3>
|
||||
{!editing && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-28">
|
||||
<option value="ID_CARD">身份证</option><option value="BANK_CARD">银行卡</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} className="shrink-0 whitespace-nowrap">
|
||||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!editing && attachments?.length ? (
|
||||
<div className="space-y-1.5">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs hover:bg-gray-100">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Paperclip className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||||
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !editing ? <div className="text-gray-400 text-xs text-center py-3">暂无附件</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt, terminateReasonMap } from "./shared"
|
||||
import { CityHistoryTab } from "./PayslipSocialInfo"
|
||||
|
||||
/** 变更历史Tab:分组显示各类变更记录 */
|
||||
export default function ChangeHistoryTab({ profile }: { profile: any }) {
|
||||
const salaryChanges = profile.salaryChanges || []
|
||||
const departmentRecords = profile.departmentRecords || []
|
||||
const socialInsRecords = profile.socialInsRecords || []
|
||||
const housingFundRecords = profile.housingFundRecords || []
|
||||
const terminations = profile.terminations || []
|
||||
|
||||
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', SALARY_CHANGE: '调薪', TRANSFER: '调部门', CITY_CHANGE: '城市变更' }
|
||||
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', SALARY_CHANGE: 'bg-indigo-50 text-indigo-600', TRANSFER: 'bg-purple-50 text-purple-600', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
|
||||
|
||||
const totalChanges = salaryChanges.length + departmentRecords.length + socialInsRecords.length + housingFundRecords.length + terminations.length
|
||||
|
||||
if (totalChanges === 0) {
|
||||
return <Card><div className="text-center py-8 text-gray-400">暂无变更记录</div></Card>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 薪资变更 */}
|
||||
{salaryChanges.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-indigo-600" />
|
||||
薪资变更历史({salaryChanges.length}条)
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">生效日期</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 text-left">原因</th>
|
||||
<th className="py-2 text-left">失效年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{salaryChanges.map((r: any, idx: number) => (
|
||||
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 font-medium">{new Date(r.effectiveDate).toLocaleDateString('zh-CN')}</td>
|
||||
<td className="py-2 text-right text-gray-600">¥{fmt(r.oldSalary)}</td>
|
||||
<td className="py-2 text-right text-gray-600">¥{fmt(r.newSalary)}</td>
|
||||
<td className="py-2 text-right font-medium text-primary">{r.newSalary >= r.oldSalary ? '+' : ''}¥{fmt(r.newSalary - r.oldSalary)}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 部门变更 */}
|
||||
{departmentRecords.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-purple-600" />
|
||||
部门变更历史({departmentRecords.length}条)
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">生效年月</th>
|
||||
<th className="py-2 text-left">原部门</th>
|
||||
<th className="py-2 text-left">新部门</th>
|
||||
<th className="py-2 text-center">类型</th>
|
||||
<th className="py-2 text-left">原因</th>
|
||||
<th className="py-2 text-left">失效年月</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{departmentRecords.map((r: any, idx: number) => (
|
||||
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 font-medium">{r.effectiveMonth}</td>
|
||||
<td className="py-2 text-gray-600">{r.oldDepartment || '无'}</td>
|
||||
<td className="py-2 font-medium">{r.newDepartment}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.reason || '-'}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.endMonth || '至今'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 参保城市变更(社保+公积金合并) */}
|
||||
{(socialInsRecords.length > 0 || housingFundRecords.length > 0) && (
|
||||
<CityHistoryTab socialInsRecords={socialInsRecords} housingFundRecords={housingFundRecords} changeTypeMap={changeTypeMap} changeTypeColor={changeTypeColor} />
|
||||
)}
|
||||
|
||||
{/* 离职/解聘记录 */}
|
||||
{terminations.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<UserX className="w-4 h-4 text-danger" />
|
||||
离职/解聘记录({terminations.length}条)
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">离职日期</th>
|
||||
<th className="py-2 text-center">离职类型</th>
|
||||
<th className="py-2 text-left">原因</th>
|
||||
<th className="py-2 text-left">备注</th>
|
||||
<th className="py-2 text-left">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terminations.map((r: any, idx: number) => (
|
||||
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 font-medium">{new Date(r.terminationDate).toLocaleDateString('zh-CN')}</td>
|
||||
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded text-xs bg-red-50 text-danger">{terminateReasonMap[r.terminationType] || r.terminationType}</span></td>
|
||||
<td className="py-2 text-gray-600 text-xs">{r.reason || '-'}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' })
|
||||
const contractFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const addContractMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
})
|
||||
|
||||
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件类型校验
|
||||
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
|
||||
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
|
||||
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
setForm({ ...form, attachmentUrl: event.target?.result as string })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
|
||||
|
||||
// 按劳动合同法自动判断续签和合同类型建议
|
||||
const contractAdvice = (() => {
|
||||
if (!contracts?.length) return null
|
||||
const fixedContracts = contracts.filter((c: any) => c.contractType === 'FIXED')
|
||||
const latestContract = contracts[0]
|
||||
const isRenewal = !!latestContract?.endDate
|
||||
const renewalCount = (latestContract?.renewalCount || 0)
|
||||
|
||||
// 连续订立二次固定期限劳动合同,第三次应订立无固定期限
|
||||
const shouldUnfixed = fixedContracts.length >= 2
|
||||
|
||||
// 连续工作满十年
|
||||
const yearsSinceHire = hireDate ? (Date.now() - new Date(hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000) : 0
|
||||
const shouldUnfixedByTenure = yearsSinceHire >= 10
|
||||
|
||||
if (shouldUnfixed || shouldUnfixedByTenure) {
|
||||
return {
|
||||
isRenewal,
|
||||
renewalCount: isRenewal ? renewalCount + 1 : renewalCount,
|
||||
suggestedType: 'UNFIXED',
|
||||
reason: shouldUnfixed
|
||||
? `已连续签订${fixedContracts.length}次固定期限合同,按《劳动合同法》第十四条应订立无固定期限合同`
|
||||
: `连续工作满${Math.floor(yearsSinceHire)}年,按《劳动合同法》第十四条应订立无固定期限合同`,
|
||||
}
|
||||
}
|
||||
|
||||
if (isRenewal) {
|
||||
return {
|
||||
isRenewal: true,
|
||||
renewalCount: renewalCount + 1,
|
||||
suggestedType: 'FIXED',
|
||||
reason: `本次为第${renewalCount + 1}次续签`,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
const handleShowForm = () => {
|
||||
if (contractAdvice?.suggestedType) {
|
||||
setForm({
|
||||
...form,
|
||||
contractType: contractAdvice.suggestedType,
|
||||
signDate: new Date().toISOString().slice(0, 10),
|
||||
startDate: contractAdvice.isRenewal && contracts[0]?.endDate
|
||||
? contracts[0].endDate.toString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10),
|
||||
endDate: '',
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
signMethod: 'PAPER',
|
||||
attachmentUrl: '',
|
||||
electronicContractNo: '',
|
||||
electronicContractUrl: '',
|
||||
})
|
||||
}
|
||||
setShowForm(!showForm)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xs font-medium">劳动合同({contracts?.length || 0}份)</h2>
|
||||
<Button size="sm" onClick={handleShowForm}>新增合同</Button>
|
||||
</div>
|
||||
|
||||
{contractAdvice && !showForm && (
|
||||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{contractAdvice.reason},建议选择「{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}」</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
{contractAdvice && (
|
||||
<div className="px-3 py-2 mb-3 rounded-md bg-amber-50 text-amber-700 text-xs flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{contractAdvice.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
|
||||
<option value="FIXED">固定期限</option>
|
||||
<option value="UNFIXED">无固定期限</option>
|
||||
</Select>
|
||||
</div>
|
||||
<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>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div><Label>合同结束日期</Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
|
||||
)}
|
||||
{form.contractType === 'FIXED' && !contractAdvice?.isRenewal && (
|
||||
<>
|
||||
<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 className="md:col-span-2 border-t pt-3">
|
||||
<Label>签订方式</Label>
|
||||
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
|
||||
<option value="PAPER">纸质签署</option>
|
||||
<option value="ELECTRONIC">电子签署</option>
|
||||
</Select>
|
||||
</div>
|
||||
{form.signMethod === 'PAPER' && (
|
||||
<div className="md:col-span-2">
|
||||
<Label>合同扫描件 *</Label>
|
||||
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
|
||||
<Paperclip className="w-4 h-4 mr-1" />上传扫描件
|
||||
</Button>
|
||||
{form.attachmentUrl && <span className="text-xs text-safe">✓ 已上传</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.signMethod === 'ELECTRONIC' && (
|
||||
<>
|
||||
<div><Label>电子合同编号 *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
|
||||
<div><Label>电子合同链接 *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
|
||||
</>
|
||||
)}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button onClick={() => addContractMutation.mutate(form)} disabled={
|
||||
addContractMutation.isPending || !form.startDate ||
|
||||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
|
||||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
|
||||
}>
|
||||
{addContractMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!contracts?.length ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无合同记录</div></Card>
|
||||
) : contracts.map((c) => (
|
||||
<Card key={c.id}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 flex-1 text-xs">
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同类型</span><span className="font-medium text-right truncate ml-2">{typeMap[c.contractType] || c.contractType}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">签订日期</span><span className="font-medium text-right truncate ml-2">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同开始</span><span className="font-medium text-right truncate ml-2">{c.startDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同结束</span><span className="font-medium text-right truncate ml-2">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">合同期限</span><span className="font-medium text-right truncate ml-2">{c.contractYears}年</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">试用期</span><span className="font-medium text-right truncate ml-2">{c.probationMonths}个月(¥{c.probationSalary})</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">签订方式</span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500 shrink-0">续签次数</span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
|
||||
{c.signMethod === 'PAPER' && (
|
||||
<div className="flex justify-between md:col-span-3">
|
||||
<span className="text-gray-500">合同扫描件</span>
|
||||
{c.attachmentUrl ? (
|
||||
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
|
||||
<Paperclip className="w-3 h-3" />查看扫描件
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">未上传</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{c.signMethod === 'ELECTRONIC' && (
|
||||
<>
|
||||
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500">电子合同编号</span><span className="font-medium">{c.electronicContractNo}</span></div>}
|
||||
{c.electronicContractUrl && (
|
||||
<div className="flex justify-between md:col-span-3">
|
||||
<span className="text-gray-500">电子合同</span>
|
||||
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />查看电子合同
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
// ========== 违纪记录管理 ==========
|
||||
|
||||
export default function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/disciplinary`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/disciplinary/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||||
})
|
||||
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xs font-medium">违纪记录({records?.length || 0}条)</h2>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增违纪记录</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>违纪日期</Label><Input type="date" value={form.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} /></div>
|
||||
<div><Label>违纪类型</Label>
|
||||
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
|
||||
{Object.entries(typeMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2"><Label>违纪事实描述</Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" /></div>
|
||||
<div><Label>严重程度</Label>
|
||||
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
|
||||
{Object.entries(severityMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>处理结果</Label>
|
||||
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
|
||||
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>处理详情</Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="扣款金额/降职说明等" /></div>
|
||||
<div><Label>见证人</Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="empAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||||
<label htmlFor="empAck" className="text-xs">员工已签字确认</label>
|
||||
</div>
|
||||
{form.employeeAck && <div><Label>确认日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.violationDate || !form.description}>
|
||||
{createMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{records?.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无违纪记录</div></Card>
|
||||
) : records?.map((r) => (
|
||||
<Card key={r.id} className="p-4">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-gray-700">{r.violationDate?.toString().slice(0, 10)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${r.severity === 'SEVERE' ? 'bg-red-100 text-red-700' : r.severity === 'SERIOUS' ? 'bg-orange-50 text-orange-600' : 'bg-amber-50 text-amber-600'}`}>
|
||||
{severityMap[r.severity] || r.severity}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{typeMap[r.violationType] || r.violationType}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 leading-relaxed">{r.description}</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-400">处理方式</span>
|
||||
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-600">{actionMap[r.action] || r.action}</span>
|
||||
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs pt-1 border-t">
|
||||
{r.employeeAck ? (
|
||||
<span className="text-safe flex items-center gap-1">
|
||||
<Check className="w-3 h-3" />已签字({r.ackDate?.toString().slice(0, 10)})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-warning flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />未签字
|
||||
</span>
|
||||
)}
|
||||
{r.witness && <span className="text-gray-400">见证人:{r.witness}</span>}
|
||||
{r.ackMethod && <span className="text-gray-400">确认方式:{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/** EmployeeProfile 组件 - 员工详情页 */
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import api from '../../lib/api'
|
||||
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
|
||||
import BasicInfo from './BasicInfo'
|
||||
import ContractInfo from './ContractInfo'
|
||||
import PayslipSocialInfo from './PayslipSocialInfo'
|
||||
import DisciplinaryInfo from './DisciplinaryInfo'
|
||||
import AttendanceOvertimeInfo from './AttendanceOvertimeInfo'
|
||||
import PerformanceInfo from './PerformanceInfo'
|
||||
import TerminationInfo from './TerminationInfo'
|
||||
import ChangeHistoryTab from './ChangeHistoryTab'
|
||||
|
||||
/**
|
||||
* 员工详情档案页
|
||||
*/
|
||||
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
|
||||
const [tab, setTab] = useState<DetailTab>('basic')
|
||||
|
||||
const { data: profile, isLoading } = useQuery<any>({
|
||||
queryKey: ['roster-profile', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/roster/${employeeId}/profile`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const isActive = profile?.status === 'ACTIVE'
|
||||
const HIDDEN_FOR_RESIGNED: DetailTab[] = ['attendance', 'performance']
|
||||
|
||||
const getTabCount = (key: DetailTab): number => {
|
||||
const dataKey = TAB_COUNT_KEYS[key]
|
||||
if (!dataKey || !profile) return 0
|
||||
const data = (profile as any)[dataKey]
|
||||
if (!Array.isArray(data)) return 0
|
||||
if (key === 'payslip') {
|
||||
const monthly = (profile as any).monthlyProcessRecords
|
||||
return data.length + (Array.isArray(monthly) ? monthly.length : 0)
|
||||
}
|
||||
if (key === 'attendance') {
|
||||
const training = (profile as any).trainingRecords
|
||||
return data.length + (Array.isArray(training) ? training.length : 0)
|
||||
}
|
||||
return data.length
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
if (!profile) return <div className="text-center py-8 text-gray-400">员工不存在</div>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
<div className="flex items-center gap-3 shrink-0 pb-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-xs font-medium">{profile.name} - 完整档案</h1>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{profile.status === 'ACTIVE' ? '在职' : '离职'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
||||
{TAB_GROUPS.map((group) => (
|
||||
<div key={group.group} className="flex items-center">
|
||||
{group.tabs
|
||||
.filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key))
|
||||
.map((t) => {
|
||||
const count = getTabCount(t.key)
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-0.5 px-1.5 py-0.5 rounded-full text-[10px] leading-none ${
|
||||
tab === t.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pt-3">
|
||||
{tab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{tab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
|
||||
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
// ========== 仲裁证据链 ==========
|
||||
|
||||
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
const { data, isLoading } = useQuery<any>({
|
||||
queryKey: ['evidence-chain', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">生成证据链中...</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">无数据</div>
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
'薪酬发放': 'bg-green-50 text-green-700 border-green-200',
|
||||
'考勤记录': 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
'违纪处理': 'bg-red-50 text-red-700 border-red-200',
|
||||
'培训签收': 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
'绩效考核': 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const text = generateEvidenceText(data)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const riskStyle: Record<string, string> = {
|
||||
DANGER: 'bg-red-50 border-red-300 text-red-700',
|
||||
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
|
||||
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
|
||||
}
|
||||
const riskIcon: Record<string, string> = {
|
||||
DANGER: '🔴',
|
||||
HIGH: '🟠',
|
||||
MEDIUM: '🟡',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" />仲裁证据链</h2>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.employee.name} · {data.employee.department} · 入职{data.employee.hireDate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">证据总数</div>
|
||||
<div className="text-xl font-bold">{data.summary.total}</div>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">已签字</div>
|
||||
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
|
||||
</div>
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">未签字</div>
|
||||
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
|
||||
</div>
|
||||
{data.summary.riskCount > 0 && (
|
||||
<div className="text-xs text-center">
|
||||
<div className="text-gray-500">风险项</div>
|
||||
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={handleExport}>导出证据链</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.risks && data.risks.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
风险提醒({data.risks.length}项)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.risks.map((r: any, i: number) => (
|
||||
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{riskIcon[r.level] || '⚠'}</span>
|
||||
<span className="font-medium text-xs">{r.title}</span>
|
||||
<span className="text-xs opacity-70">{r.category}</span>
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-90">{r.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.evidence.map((e: any, i: number) => (
|
||||
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
|
||||
{e.category}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-xs">{e.title}</span>
|
||||
<span className="text-xs text-gray-400">{e.date}</span>
|
||||
{e.acknowledged === true && <span className="text-xs text-safe">✓ 已签字</span>}
|
||||
{e.acknowledged === false && <span className="text-xs text-warning">⚠ 未签字</span>}
|
||||
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger">⚠ 高风险</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function generateEvidenceText(data: any): string {
|
||||
const lines: string[] = []
|
||||
lines.push('========================================')
|
||||
lines.push(' 劳动仲裁证据链')
|
||||
lines.push('========================================')
|
||||
lines.push('')
|
||||
lines.push(`员工姓名:${data.employee.name}`)
|
||||
lines.push(`部门:${data.employee.department}`)
|
||||
lines.push(`入职日期:${data.employee.hireDate}`)
|
||||
lines.push(`状态:${data.employee.status === 'ACTIVE' ? '在职' : '离职'}`)
|
||||
lines.push('')
|
||||
lines.push(`证据总数:${data.summary.total} 条`)
|
||||
lines.push(`已签字:${data.summary.signed} 条`)
|
||||
lines.push(`未签字:${data.summary.unsigned} 条`)
|
||||
lines.push('')
|
||||
lines.push('----------------------------------------')
|
||||
lines.push('')
|
||||
|
||||
let currentCategory = ''
|
||||
data.evidence.forEach((e: any, i: number) => {
|
||||
if (e.category !== currentCategory) {
|
||||
currentCategory = e.category
|
||||
lines.push(`【${currentCategory}】`)
|
||||
lines.push('')
|
||||
}
|
||||
lines.push(`${i + 1}. ${e.title}(${e.date})`)
|
||||
lines.push(` ${e.description}`)
|
||||
if (e.acknowledged === true) lines.push(' [已签字确认]')
|
||||
if (e.acknowledged === false) lines.push(' [未签字]')
|
||||
lines.push('')
|
||||
})
|
||||
|
||||
lines.push('----------------------------------------')
|
||||
lines.push(`导出时间:${new Date().toLocaleString('zh-CN')}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
/** 薪酬社保合并组件(工资条 / 缴纳记录) */
|
||||
export default function PayslipSocialInfo({ payslips, monthlyProcessRecords }: { payslips: any[]; socialInsRecords: any[]; housingFundRecords: any[]; monthlyProcessRecords: any[] }) {
|
||||
const [subTab, setSubTab] = useState<'payslip' | 'monthly'>('payslip')
|
||||
|
||||
const changeTypeMap: Record<string, string> = { ONBOARDING: '入职', REHIRE: '重新入职', ADJUST: '调基', TERMINATION: '离职/解聘', CITY_CHANGE: '城市变更' }
|
||||
const changeTypeColor: Record<string, string> = { ONBOARDING: 'bg-green-50 text-safe', REHIRE: 'bg-blue-50 text-blue-600', ADJUST: 'bg-amber-50 text-amber-600', TERMINATION: 'bg-red-50 text-danger', CITY_CHANGE: 'bg-cyan-50 text-cyan-600' }
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setSubTab('payslip')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'payslip' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
工资条({payslips?.length || 0}条)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('monthly')}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${subTab === 'monthly' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
|
||||
>
|
||||
缴纳记录({monthlyProcessRecords?.length || 0}条)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subTab === 'payslip' && (
|
||||
<>
|
||||
{!payslips?.length ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无工资条记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">月份</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payslips.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{p.month}</td>
|
||||
<td className="py-2 text-right text-gray-600">¥{fmt(p.baseSalary)}</td>
|
||||
<td className="py-2 text-right text-gray-600">{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}</td>
|
||||
<td className="py-2 text-right text-gray-600">{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}</td>
|
||||
<td className="py-2 text-right text-gray-600">{p.deduction > 0 ? `-¥${fmt(p.deduction)}` : '-'}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(p.totalPay)}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? (
|
||||
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs">已确认</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs">未确认</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 bg-gray-50">
|
||||
<td className="py-2 font-medium">合计</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">¥{fmt(payslips.reduce((s, p) => s + (p.baseSalary || 0), 0))}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.overtimePay || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.overtimePay || 0), 0))}` : '-'}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.allowance || 0), 0) > 0 ? `¥${fmt(payslips.reduce((s, p) => s + (p.allowance || 0), 0))}` : '-'}</td>
|
||||
<td className="py-2 text-right font-medium text-gray-600">{payslips.reduce((s, p) => s + (p.deduction || 0), 0) > 0 ? `-¥${fmt(payslips.reduce((s, p) => s + (p.deduction || 0), 0))}` : '-'}</td>
|
||||
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(payslips.reduce((s, p) => s + (p.totalPay || 0), 0))}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{subTab === 'monthly' && (
|
||||
<>
|
||||
{!monthlyProcessRecords?.length ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无缴纳记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">办理月份</th>
|
||||
<th className="py-2 text-center">类型</th>
|
||||
<th className="py-2 text-center">城市</th>
|
||||
<th className="py-2 text-center">状态</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 text-left">办理时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monthlyProcessRecords.map((r, idx) => {
|
||||
const d = r.detail
|
||||
const isSocial = r.type === 'SOCIAL'
|
||||
const orgAmt = isSocial ? d?.totalOrg : d?.orgAmount
|
||||
const empAmt = isSocial ? d?.totalEmp : d?.empAmount
|
||||
const total = isSocial ? d?.total : d?.total
|
||||
return (
|
||||
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 font-medium">{r.month}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${isSocial ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{isSocial ? '社保' : '公积金'}</span></td>
|
||||
<td className="py-2 text-center text-xs text-gray-600">{r.city || '-'}</td>
|
||||
<td className="py-2 text-center"><span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs">已缴纳</span></td>
|
||||
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
|
||||
<td className="py-2 text-right text-danger">{orgAmt != null ? `¥${fmt(orgAmt)}` : '-'}</td>
|
||||
<td className="py-2 text-right text-warning">{empAmt != null ? `¥${fmt(empAmt)}` : '-'}</td>
|
||||
<td className="py-2 text-right font-medium text-primary">{total != null ? `¥${fmt(total)}` : '-'}</td>
|
||||
<td className="py-2 text-center text-xs text-gray-500">{r.changeType}</td>
|
||||
<td className="py-2 text-gray-400 text-xs">{new Date(r.processedAt).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 参保城市变更历史组件(含修正功能) */
|
||||
export function CityHistoryTab({ socialInsRecords, housingFundRecords, changeTypeMap, changeTypeColor }: { socialInsRecords: any[]; housingFundRecords: any[]; changeTypeMap: Record<string, string>; changeTypeColor: Record<string, string> }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [correctForm, setCorrectForm] = useState({ city: '', base: '', startMonth: '', endMonth: '', changeType: '', remark: '', reason: '' })
|
||||
|
||||
const correctMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const cat = editing.cat
|
||||
const url = cat === '社保'
|
||||
? `/social/records/social/${editing.id}/correct`
|
||||
: `/social/records/housing/${editing.id}/correct`
|
||||
const res = await api.put(url, data) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('记录已修正')
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setEditing(null)
|
||||
},
|
||||
onError: () => toast.error('修正失败'),
|
||||
})
|
||||
|
||||
const handleCorrect = () => {
|
||||
correctMutation.mutate({
|
||||
city: correctForm.city || undefined,
|
||||
base: correctForm.base ? Number(correctForm.base) : undefined,
|
||||
startMonth: correctForm.startMonth || undefined,
|
||||
endMonth: correctForm.endMonth || undefined,
|
||||
changeType: correctForm.changeType || undefined,
|
||||
remark: correctForm.remark || undefined,
|
||||
reason: correctForm.reason || '数据修正',
|
||||
})
|
||||
}
|
||||
|
||||
const openCorrect = (r: any) => {
|
||||
setEditing(r)
|
||||
setCorrectForm({
|
||||
city: r.city || '',
|
||||
base: String(r.base || ''),
|
||||
startMonth: r.startMonth || '',
|
||||
endMonth: r.endMonth || '',
|
||||
changeType: r.changeType || '',
|
||||
remark: r.remark || '',
|
||||
reason: '',
|
||||
})
|
||||
}
|
||||
|
||||
const allRecords = [
|
||||
...(socialInsRecords || []).map((r: any) => ({ ...r, cat: '社保' })),
|
||||
...(housingFundRecords || []).map((r: any) => ({ ...r, cat: '公积金' })),
|
||||
].sort((a, b) => b.startMonth.localeCompare(a.startMonth))
|
||||
|
||||
if (!allRecords.length) return <Card><div className="text-center py-8 text-gray-400">暂无参保记录</div></Card>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="text-xs text-gray-500 mb-3">参保城市变更历史(社保 + 公积金记录按时间倒序,点击「修正」可直接修改错误数据并记录审计日志)</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">开始年月</th>
|
||||
<th className="py-2 text-left">截止年月</th>
|
||||
<th className="py-2 text-center">类型</th>
|
||||
<th className="py-2 text-left">参保城市</th>
|
||||
<th className="py-2 text-right">缴费基数</th>
|
||||
<th className="py-2 text-center">变动类型</th>
|
||||
<th className="py-2 text-left">备注</th>
|
||||
<th className="py-2 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allRecords.map((r, idx) => (
|
||||
<tr key={idx} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2 font-medium">{r.startMonth}</td>
|
||||
<td className="py-2 text-gray-400">{r.endMonth || '至今'}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${r.cat === '社保' ? 'bg-blue-50 text-blue-600' : 'bg-purple-50 text-purple-600'}`}>{r.cat}</span></td>
|
||||
<td className="py-2"><span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs">{r.city || '-'}</span></td>
|
||||
<td className="py-2 text-right text-gray-600">¥{fmt(r.base)}</td>
|
||||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${changeTypeColor[r.changeType] || 'bg-gray-100 text-gray-500'}`}>{changeTypeMap[r.changeType] || r.changeType}</span></td>
|
||||
<td className="py-2 text-gray-400 text-xs">{r.remark || '-'}</td>
|
||||
<td className="py-2 text-center"><button onClick={() => openCorrect(r)} className="text-xs text-primary hover:underline">修正</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{editing && (
|
||||
<Modal open={true} onClose={() => setEditing(null)} title={`修正${editing.cat}记录`}>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-amber-50 text-amber-700 text-xs px-3 py-2 rounded-md">
|
||||
此操作将直接修改记录并写入审计日志(记录修改前后的值和修正原因),不会创建新记录。
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>参保城市</Label>
|
||||
<Input value={correctForm.city} onChange={(e) => setCorrectForm({ ...correctForm, city: e.target.value })} placeholder="如 北京" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>缴费基数</Label>
|
||||
<Input type="number" value={correctForm.base} onChange={(e) => setCorrectForm({ ...correctForm, base: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>开始年月</Label>
|
||||
<Input type="month" value={correctForm.startMonth} onChange={(e) => setCorrectForm({ ...correctForm, startMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>截止年月</Label>
|
||||
<Input type="month" value={correctForm.endMonth} onChange={(e) => setCorrectForm({ ...correctForm, endMonth: e.target.value })} placeholder="留空=至今" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>变动类型</Label>
|
||||
<Select value={correctForm.changeType} onChange={(e) => setCorrectForm({ ...correctForm, changeType: e.target.value })}>
|
||||
<option value="ONBOARDING">入职</option>
|
||||
<option value="REHIRE">重新入职</option>
|
||||
<option value="ADJUST">调基</option>
|
||||
<option value="CITY_CHANGE">城市变更</option>
|
||||
<option value="TERMINATION">离职/解聘</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={correctForm.remark} onChange={(e) => setCorrectForm({ ...correctForm, remark: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>修正原因(必填,写入审计日志)</Label>
|
||||
<Input value={correctForm.reason} onChange={(e) => setCorrectForm({ ...correctForm, reason: e.target.value })} placeholder="如:城市录入错误" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}>取消</Button>
|
||||
<Button size="sm" onClick={handleCorrect} disabled={correctMutation.isPending}>{correctMutation.isPending ? '保存中...' : '确认修正'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
// ========== 绩效记录管理 ==========
|
||||
|
||||
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/performance`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/performance/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||||
})
|
||||
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xs font-medium">绩效考核记录({records?.length || 0}条)</h2>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增绩效记录</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div><Label>考核周期</Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
|
||||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
|
||||
<div><Label>等级</Label>
|
||||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div><Label>考核结果</Label>
|
||||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||||
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2"><Label>考核评语</Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
|
||||
<div className="md:col-span-2"><Label>改进计划(不胜任时填写)</Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
|
||||
<div><Label>考核人</Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||||
<label htmlFor="perfAck" className="text-xs">员工已签字确认</label>
|
||||
</div>
|
||||
{form.employeeAck && <div><Label>确认日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{records?.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无绩效记录</div></Card>
|
||||
) : records?.map((r) => (
|
||||
<Card key={r.id} className="p-4">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-gray-700">{r.period}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
||||
{resultMap[r.result] || r.result}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">得分 {r.score} · 等级 {r.grade}</span>
|
||||
</div>
|
||||
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
|
||||
{r.improvementPlan && (
|
||||
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
|
||||
<span className="font-medium">改进计划</span>:{r.improvementPlan}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
|
||||
{r.employeeAck ? (
|
||||
<span className="text-safe flex items-center gap-1"><Check className="w-3 h-3" />已签字({r.ackDate?.toString().slice(0, 10)})</span>
|
||||
) : (
|
||||
<span className="text-warning flex items-center gap-1"><AlertTriangle className="w-3 h-3" />未签字</span>
|
||||
)}
|
||||
{r.reviewer && <span>考核人:{r.reviewer}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { fmt } from "./shared"
|
||||
import { generateEvidenceText } from "./EvidenceChain"
|
||||
|
||||
export default function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) {
|
||||
const [printRecord, setPrintRecord] = useState<any | null>(null)
|
||||
const [showEvidence, setShowEvidence] = useState(false)
|
||||
|
||||
const reasonMap: Record<string, string> = {
|
||||
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
|
||||
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
|
||||
RESIGNATION: '员工主动离职',
|
||||
}
|
||||
const legalBasisMap: Record<string, string> = {
|
||||
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
|
||||
NONFAULT: '《劳动合同法》第40条', LAYOFF: '《劳动合同法》第41条',
|
||||
EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条',
|
||||
}
|
||||
|
||||
const { data: evidenceChain, isLoading: evidenceLoading } = useQuery<any>({
|
||||
queryKey: ['evidence-chain', employeeId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||||
return res.data
|
||||
},
|
||||
enabled: !!printRecord || showEvidence,
|
||||
})
|
||||
|
||||
const validRecords = (records || []).filter((t: any) => t.status !== 'CANCELLED')
|
||||
const cancelledRecords = (records || []).filter((t: any) => t.status === 'CANCELLED')
|
||||
if (!validRecords.length && !cancelledRecords.length) return <Card><div className="text-center py-8 text-gray-400">暂无离职/解聘记录</div></Card>
|
||||
|
||||
if (printRecord) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setPrintRecord(null)} className="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-xs">
|
||||
<X className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-1" />打印材料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 解聘通知书 */}
|
||||
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
|
||||
<div className="text-center">
|
||||
<h2 className="text-base font-bold">解除劳动合同通知书</h2>
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 space-y-3">
|
||||
<p><strong>{profile.name}</strong> 先生/女士:</p>
|
||||
<p>
|
||||
您于 <strong>{profile.hireDate?.toString().slice(0, 10)}</strong> 入职我公司{profile.department}部门。
|
||||
因 <strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> 原因,公司决定于 <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong> 起解除与您的劳动合同。
|
||||
</p>
|
||||
<p>解除依据:{legalBasisMap[printRecord.reason] || ''}</p>
|
||||
<p>经济补偿金:<strong>¥{fmt(printRecord.compensation)}</strong></p>
|
||||
{printRecord.remark && <p>备注:{printRecord.remark}</p>}
|
||||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||||
<div className="text-right mt-6 space-y-1">
|
||||
<p>公司(盖章)</p>
|
||||
<p className="text-gray-400">{printRecord.terminationDate?.toString().slice(0, 10)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 费用结算明细 */}
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-xs font-medium flex items-center gap-2"><Calculator className="w-4 h-4" />费用结算明细</h3>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between"><span>员工</span><span>{profile.name}({profile.department})</span></div>
|
||||
<div className="flex justify-between"><span>入职日期</span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(profile.monthlySalary)}/月</span></div>
|
||||
<div className="flex justify-between"><span>解聘日期</span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>解聘原因</span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{fmt(printRecord.compensation)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 合规检查清单 */}
|
||||
{printRecord.checklist && Object.keys(printRecord.checklist).length > 0 && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-xs font-medium flex items-center gap-2"><Shield className="w-4 h-4" />合规检查清单</h3>
|
||||
<div className="text-xs space-y-1">
|
||||
{Object.entries(printRecord.checklist).map(([key, passed]: [string, any]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className={passed ? 'text-safe' : 'text-danger'}>{passed ? '✓' : '✗'}</span>
|
||||
<span className={passed ? '' : 'text-gray-500'}>{key}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. 风险评估 */}
|
||||
{printRecord.riskLevel && printRecord.riskLevel !== 'SAFE' && (
|
||||
<div className="border rounded-lg p-4 space-y-2">
|
||||
<h3 className="text-xs font-medium flex items-center gap-2"><AlertTriangle className="w-4 h-4" />风险评估</h3>
|
||||
<div className="text-xs">
|
||||
<div className={`px-3 py-2 rounded-md ${printRecord.riskLevel === 'DANGER' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-800'}`}>
|
||||
风险等级:{printRecord.riskLevel === 'DANGER' ? '高风险' : '注意'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. 仲裁证据链 */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-xs 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-xs font-medium text-gray-700">{category}</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-xs text-gray-400">加载证据链中...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{validRecords.map((t) => (
|
||||
<Card key={t.id} className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-gray-700">{t.terminationDate?.toString().slice(0, 10)}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${t.type === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{reasonMap[t.reason] || t.reason}</span>
|
||||
{t.type !== 'RESIGNATION' && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
||||
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
|
||||
</span>
|
||||
)}
|
||||
{t.status && (
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${t.status === 'COMPLETED' ? 'bg-green-50 text-safe' : t.status === 'PENDING_APPROVAL' ? 'bg-amber-50 text-amber-700' : t.status === 'APPROVED' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{t.status === 'DRAFT' ? '草稿' : t.status === 'PENDING_APPROVAL' ? '待审批' : t.status === 'APPROVED' ? '已审批' : t.status === 'COMPLETED' ? '已完成' : t.status === 'REJECTED' ? '已驳回' : t.status === 'EXECUTING' ? '执行中' : t.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
|
||||
{t.type === 'RESIGNATION' ? (
|
||||
<>
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">离职原因</span>
|
||||
<span className="font-medium text-gray-700 text-right truncate ml-2">{t.resignationReason || '-'}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">经济补偿金</span>
|
||||
<span className="font-medium text-gray-700 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">法律依据</span>
|
||||
<span className="font-medium text-gray-700 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{t.remark && <div className="text-xs text-gray-500 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
|
||||
<div className="flex justify-end">
|
||||
{t.type !== 'RESIGNATION' && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
|
||||
<Printer className="w-4 h-4 mr-1" />打印解聘材料
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{cancelledRecords.length > 0 && (
|
||||
<>
|
||||
{validRecords.length > 0 && <div className="text-xs text-gray-400 pt-2">已撤销记录</div>}
|
||||
{cancelledRecords.map((t) => (
|
||||
<Card key={t.id} className="p-4 opacity-60">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-gray-500">{t.terminationDate?.toString().slice(0, 10)}</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
|
||||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{reasonMap[t.reason] || t.reason}</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-red-50 text-red-500 line-through">已撤销</span>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 text-xs">
|
||||
{t.type === 'RESIGNATION' ? (
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">离职原因</span>
|
||||
<span className="font-medium text-gray-500 text-right truncate ml-2">{t.resignationReason || '-'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">经济补偿金</span>
|
||||
<span className="font-medium text-gray-500 text-right truncate ml-2">¥{fmt(t.compensation)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-400 shrink-0">法律依据</span>
|
||||
<span className="font-medium text-gray-500 text-right truncate ml-2">{legalBasisMap[t.reason] || '-'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{t.remark && <div className="text-xs text-gray-400 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 仲裁证据链 */}
|
||||
<div className="pt-3 border-t">
|
||||
<button
|
||||
onClick={() => setShowEvidence(!showEvidence)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
仲裁证据链
|
||||
{evidenceChain && (
|
||||
<span className="text-gray-400">
|
||||
({evidenceChain.summary?.total || 0}条证据,已签{evidenceChain.summary?.signed || 0},未签{evidenceChain.summary?.unsigned || 0})
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400">{showEvidence ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{showEvidence && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{evidenceLoading ? (
|
||||
<div className="text-xs text-gray-400 text-center py-4">生成证据链中...</div>
|
||||
) : evidenceChain ? (
|
||||
<>
|
||||
{evidenceChain.risks && evidenceChain.risks.length > 0 && (
|
||||
<Card className="p-3">
|
||||
<div className="text-xs font-medium mb-2 flex items-center gap-1 text-danger">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />风险提醒({evidenceChain.risks.length}项)
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{evidenceChain.risks.map((r: any, i: number) => (
|
||||
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
|
||||
<span className="font-medium">{r.title}</span>
|
||||
<span className="text-xs opacity-70 ml-2">{r.category}</span>
|
||||
<div className="opacity-90 mt-0.5">{r.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{evidenceChain.evidence?.map((e: any, i: number) => (
|
||||
<Card key={i} className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="px-1.5 py-0.5 rounded border text-xs shrink-0 bg-gray-50 text-gray-600 border-gray-200">{e.category}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-xs">{e.title}</span>
|
||||
<span className="text-xs text-gray-400">{e.date}</span>
|
||||
{e.acknowledged === true && <span className="text-xs text-safe">✓已签</span>}
|
||||
{e.acknowledged === false && <span className="text-xs text-warning">⚠未签</span>}
|
||||
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger">⚠高风险</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{e.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = generateEvidenceText(evidenceChain)
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `仲裁证据链_${evidenceChain.employee?.name}_${new Date().toISOString().slice(0, 10)}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
导出证据链
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400 text-center py-4">无数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useState, useRef } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
|
||||
import { useUnsavedChanges } from "../../hooks/useUnsavedChanges"
|
||||
import { fmt } from "./shared"
|
||||
|
||||
export function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const [form, setForm] = useState({
|
||||
newSalary: '',
|
||||
effectiveDate: todayStr,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
newSalary: parseFloat(form.newSalary),
|
||||
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
||||
reason: form.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`调薪 - ${employee.name}`}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">{employee.name} - {employee.department}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>当前月薪</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">¥{fmt(employee.monthlySalary)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>新月薪 *</Label>
|
||||
<Input type="number" value={form.newSalary} onChange={(e) => setForm({ ...form, newSalary: e.target.value })} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>生效日期 *</Label>
|
||||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>调薪原因(选填)</Label>
|
||||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" />
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调薪'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const [form, setForm] = useState({
|
||||
newDepartment: employee.department || '',
|
||||
effectiveDate: todayStr,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
newDepartment: form.newDepartment,
|
||||
effectiveDate: new Date(form.effectiveDate).toISOString(),
|
||||
reason: form.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`调部门 - ${employee.name}`}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>当前部门</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>新部门 *</Label>
|
||||
<Input value={form.newDepartment} onChange={(e) => setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>生效日期 *</Label>
|
||||
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>调部门原因(选填)</Label>
|
||||
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" />
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调部门'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResignModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
terminationDate: new Date().toISOString().slice(0, 10),
|
||||
resignationReason: '个人原因',
|
||||
remark: '',
|
||||
socialInsEndMonth: '',
|
||||
housingFundEndMonth: '',
|
||||
})
|
||||
|
||||
const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : ''
|
||||
|
||||
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
employeeId: employee.id,
|
||||
terminationDate: new Date(form.terminationDate).toISOString(),
|
||||
resignationReason: form.resignationReason,
|
||||
remark: form.remark || undefined,
|
||||
socialInsEndMonth: form.socialInsEndMonth || terminationMonth,
|
||||
housingFundEndMonth: form.housingFundEndMonth || terminationMonth,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
|
||||
员工主动离职,不涉及经济补偿金。离职日期可在未来(提前办理),到日期后状态自动变为离职。
|
||||
</div>
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.terminationDate}
|
||||
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>离职原因</Label>
|
||||
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
|
||||
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金截止缴费年月</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与离职日期同月,可手动修改</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>社保截止年月</Label>
|
||||
<Input type="month" value={form.socialInsEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, socialInsEndMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金截止年月</Label>
|
||||
<Input type="month" value={form.housingFundEndMonth || terminationMonth} onChange={(e) => setForm({ ...form, housingFundEndMonth: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs mt-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
截止缴费年月与离职日期不在同月,请确认是否为多缴/少缴月份。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注(选填)</Label>
|
||||
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? '提交中...' : '确认离职'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
employee: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
|
||||
queryKey: ['contract-types'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/contract-types') as any
|
||||
return res.data || []
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const defaultEndDate = (() => {
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 3)
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
hireDate: todayStr,
|
||||
department: employee.department || '',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '',
|
||||
startDate: todayStr,
|
||||
endDate: defaultEndDate,
|
||||
contractYears: 3,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
socialInsBase: '', socialInsStartMonth: '',
|
||||
housingFundBase: '', housingFundStartMonth: '',
|
||||
})
|
||||
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
||||
|
||||
// 计算合同月数
|
||||
const contractMonths = (() => {
|
||||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||||
if (form.endDate) {
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(form.endDate)
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
}
|
||||
return form.contractYears * 12
|
||||
})()
|
||||
|
||||
// 试用期上限(劳动合同法第19条)
|
||||
const probationMax = (() => {
|
||||
if (contractMonths >= 36) return 6
|
||||
if (contractMonths >= 12) return 2
|
||||
if (contractMonths >= 3) return 1
|
||||
return 0
|
||||
})()
|
||||
|
||||
const probationError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||||
return ''
|
||||
})()
|
||||
|
||||
const monthlySalaryNum = employee?.monthlySalary || 0
|
||||
const probationSalaryError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
|
||||
// 合同结束日期自动计算
|
||||
const handleContractYearsChange = (years: number) => {
|
||||
if (!form.startDate || years <= 0) {
|
||||
setForm({ ...form, contractYears: years, endDate: '' })
|
||||
return
|
||||
}
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(start)
|
||||
end.setFullYear(end.getFullYear() + years)
|
||||
end.setDate(end.getDate() - 1)
|
||||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||||
}
|
||||
|
||||
// 合同结束日期变更 → 自动计算签约年限
|
||||
const handleEndDateChange = (endDate: string) => {
|
||||
if (!form.startDate || !endDate) {
|
||||
setForm({ ...form, endDate, contractYears: 0 })
|
||||
return
|
||||
}
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(endDate)
|
||||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||||
}
|
||||
|
||||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||||
const handleHireDateChange = (hireDate: string) => {
|
||||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||||
const start = new Date(hireDate)
|
||||
const end = new Date(start)
|
||||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||||
end.setDate(end.getDate() - 1)
|
||||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||||
} else {
|
||||
setForm({ ...form, hireDate, startDate: hireDate })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
department: form.department,
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
const canSubmit = form.hireDate
|
||||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||||
&& !probationError && !probationSalaryError
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`重新入职 - ${employee.name}`}>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
|
||||
复用已有基本信息,只需填写新入职日期和劳动合同。
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>员工</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>部门 *</Label>
|
||||
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>新入职日期 *</Label>
|
||||
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与月工资一致,可手动修改</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保开始年月</Label>
|
||||
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase || employee?.monthlySalary || ''} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金开始年月</Label>
|
||||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="col-span-1">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => {
|
||||
const ct = contractTypes.find(t => t.value === e.target.value)
|
||||
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
|
||||
}}>
|
||||
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{form.contractType !== 'UNSIGNED' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>签订日期</Label>
|
||||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||||
<div className="text-xs text-gray-400 mt-0.5">留空表示尚未签订</div>
|
||||
</div>
|
||||
<div><Label>合同开始日期</Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div>
|
||||
<Label>签约时长(年)</Label>
|
||||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||||
</div>
|
||||
)}
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label>试用期(月)</Label>
|
||||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||||
{contractMonths > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">法定上限:{probationMax}个月</div>
|
||||
)}
|
||||
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
)}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-danger">
|
||||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '提交中...' : '确认入职'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
onClose: () => void
|
||||
onSubmit: (data: any) => void
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const { data: cities = ['北京'] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/config/cities') as any
|
||||
return res.data?.length ? res.data : ['北京']
|
||||
},
|
||||
})
|
||||
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
|
||||
queryKey: ['contract-types'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/contract-types') as any
|
||||
return res.data || []
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const defaultEndDate = (() => {
|
||||
const d = new Date()
|
||||
d.setFullYear(d.getFullYear() + 3)
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||
city: '北京',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||||
socialInsBase: '', socialInsStartMonth: '',
|
||||
housingFundBase: '', housingFundStartMonth: '',
|
||||
})
|
||||
|
||||
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
||||
|
||||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||||
const handleHireDateChange = (hireDate: string) => {
|
||||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||||
const start = new Date(hireDate)
|
||||
const end = new Date(start)
|
||||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||||
end.setDate(end.getDate() - 1)
|
||||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||||
} else {
|
||||
setForm({ ...form, hireDate, startDate: hireDate })
|
||||
}
|
||||
}
|
||||
|
||||
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
|
||||
const handleIdCardChange = (idCard: string) => {
|
||||
let gender = form.gender
|
||||
if (idCard.length >= 17) {
|
||||
const digit = parseInt(idCard[16])
|
||||
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
|
||||
}
|
||||
setForm({ ...form, idCardNumber: idCard, gender })
|
||||
}
|
||||
|
||||
// 计算合同月数
|
||||
const contractMonths = (() => {
|
||||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||||
if (form.endDate) {
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(form.endDate)
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
}
|
||||
return form.contractYears * 12
|
||||
})()
|
||||
|
||||
// 试用期上限(劳动合同法第19条)
|
||||
const probationMax = (() => {
|
||||
if (contractMonths >= 36) return 6
|
||||
if (contractMonths >= 12) return 2
|
||||
if (contractMonths >= 3) return 1
|
||||
return 0
|
||||
})()
|
||||
|
||||
const probationError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||||
return ''
|
||||
})()
|
||||
|
||||
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
||||
const probationSalaryError = (() => {
|
||||
if (form.probationMonths <= 0) return ''
|
||||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
|
||||
// 合同结束日期自动计算
|
||||
const handleContractYearsChange = (years: number) => {
|
||||
if (!form.startDate || years <= 0) {
|
||||
setForm({ ...form, contractYears: years, endDate: '' })
|
||||
return
|
||||
}
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(start)
|
||||
end.setFullYear(end.getFullYear() + years)
|
||||
end.setDate(end.getDate() - 1)
|
||||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||||
}
|
||||
|
||||
// 合同结束日期变更 → 自动计算签约年限
|
||||
const handleEndDateChange = (endDate: string) => {
|
||||
if (!form.startDate || !endDate) {
|
||||
setForm({ ...form, endDate, contractYears: 0 })
|
||||
return
|
||||
}
|
||||
const start = new Date(form.startDate)
|
||||
const end = new Date(endDate)
|
||||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: any = {
|
||||
name: form.name, department: form.department,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||
idCardNumber: form.idCardNumber || undefined,
|
||||
phone: form.phone || undefined,
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
|
||||
&& form.idCardNumber.length >= 18
|
||||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||||
&& !probationError && !probationSalaryError
|
||||
|
||||
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
|
||||
useUnsavedChanges(isDirty)
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="添加员工" size="xl">
|
||||
<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-4 gap-4">
|
||||
<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><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||
<div><Label>性别</Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
<div><Label>月工资 *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>参保城市</Label><Select value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
|
||||
</div>
|
||||
{/* 社保公积金 */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Briefcase className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">社保公积金</span>
|
||||
<span className="text-xs text-gray-500">默认与月工资一致,可手动修改</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase || form.monthlySalary} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保开始年月</Label>
|
||||
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase || form.monthlySalary} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金开始年月</Label>
|
||||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 合同信息 */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileSignature className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">合同信息</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="col-span-1">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => {
|
||||
const ct = contractTypes.find(t => t.value === e.target.value)
|
||||
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
|
||||
}}>
|
||||
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{form.contractType !== 'UNSIGNED' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>签订日期</Label>
|
||||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||||
<div className="text-xs text-gray-500 mt-1">留空表示尚未签订</div>
|
||||
</div>
|
||||
<div><Label>合同开始日期</Label><div className="text-sm text-gray-600 py-2">{form.startDate || '随入职日期'}</div></div>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div>
|
||||
<Label>签约时长(年)</Label>
|
||||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||||
</div>
|
||||
)}
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{form.contractType === 'FIXED' && (
|
||||
<div className="text-xs text-gray-500">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
)}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label>试用期(月)</Label>
|
||||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||||
{contractMonths > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">法定上限:{probationMax}个月</div>
|
||||
)}
|
||||
{probationError && <div className="text-xs text-danger mt-1">{probationError}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
)}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-gray-200">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '保存'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Roster 共享类型与常量 */
|
||||
|
||||
/** 金额格式化:保留两位小数 + 千分位 */
|
||||
export const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
/** 解聘原因映射 */
|
||||
export const terminateReasonMap: Record<string, string> = {
|
||||
NEGOTIATED: '协商解除',
|
||||
FAULT: '员工过错',
|
||||
NONFAULT: '非过错解除',
|
||||
LAYOFF: '经济性裁员',
|
||||
EXPIRED: '合同到期不续签',
|
||||
}
|
||||
|
||||
/** 详情页 Tab 类型 */
|
||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history'
|
||||
|
||||
/** Tab 分组 */
|
||||
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
|
||||
|
||||
/** Tab 分组配置 */
|
||||
export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: string; icon: any }[] }[] = [
|
||||
{
|
||||
group: '人事信息',
|
||||
tabs: [
|
||||
{ key: 'basic', label: '基本信息', icon: null },
|
||||
{ key: 'contract', label: '劳动合同', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '薪酬',
|
||||
tabs: [
|
||||
{ key: 'payslip', label: '薪酬社保', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '考勤绩效',
|
||||
tabs: [
|
||||
{ key: 'attendance', label: '考勤培训', icon: null },
|
||||
{ key: 'performance', label: '绩效考核', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '风险合规',
|
||||
tabs: [
|
||||
{ key: 'disciplinary', label: '违纪记录', icon: null },
|
||||
{ key: 'termination', label: '离职/解聘', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '变更历史',
|
||||
tabs: [
|
||||
{ key: 'history', label: '变更历史', icon: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Tab 计数字段映射 */
|
||||
export const TAB_COUNT_KEYS: Record<string, string> = {
|
||||
contract: 'contracts',
|
||||
payslip: 'payslips',
|
||||
attendance: 'attendanceRecords',
|
||||
disciplinary: 'disciplinaryRecords',
|
||||
performance: 'performanceRecords',
|
||||
termination: 'terminations',
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 年度价值报告页面
|
||||
* 量化系统为企业创造的价值:ROI + 规避损失 + 节约工时 + 月度时间轴
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { TrendingUp, Save, History, Download, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}万`
|
||||
return `¥${n.toLocaleString('zh-CN')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 年度价值报告页面
|
||||
*/
|
||||
export default function AnnualValueReport() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [year, setYear] = useState(currentYear)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
|
||||
const { data: report, isLoading } = useQuery<any>({
|
||||
queryKey: ['annual-value', year],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/dashboard/annual-value?year=${year}`) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: history } = useQuery<any>({
|
||||
queryKey: ['annual-value-history'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/annual-value/history') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showHistory,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/dashboard/annual-value/save', { year }) as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('报告已保存')
|
||||
queryClient.invalidateQueries({ queryKey: ['annual-value-history'] })
|
||||
},
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
}
|
||||
|
||||
if (!report) return null
|
||||
|
||||
const metricCards = [
|
||||
{ label: '规避损失', value: fmtMoney(report.lossAvoided || 0), icon: ShieldCheck, color: 'text-safe', bg: 'bg-green-50' },
|
||||
{ label: '节约工时', value: `${report.timeSaved || 0}h`, icon: Clock, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ label: '节约成本', value: fmtMoney(report.costSaved || 0), icon: DollarSign, color: 'text-primary', bg: 'bg-indigo-50' },
|
||||
{ label: '总价值', value: fmtMoney(report.totalValue || 0), icon: TrendingUp, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{ label: '处理风险', value: report.metrics?.risksResolved ?? 0, icon: ShieldCheck, color: 'text-danger' },
|
||||
{ label: 'AI 咨询', value: report.metrics?.aiQueries ?? 0, icon: Sparkles, color: 'text-primary' },
|
||||
{ label: '合同审查', value: report.metrics?.contractsReviewed ?? 0, icon: FileText, color: 'text-blue-600' },
|
||||
{ label: '签订合同', value: report.metrics?.contractsSigned ?? 0, icon: FileText, color: 'text-safe' },
|
||||
{ label: '算薪批次', value: report.metrics?.payrollProcessed ?? 0, icon: Calculator, color: 'text-amber-600' },
|
||||
{ label: '在管员工', value: report.metrics?.employeesManaged ?? 0, icon: Users, color: 'text-gray-700' },
|
||||
{ label: '解聘处理', value: report.metrics?.terminations ?? 0, icon: Users, color: 'text-orange-600' },
|
||||
{ label: '制度公示', value: report.metrics?.policiesPublished ?? 0, icon: FileText, color: 'text-purple-600' },
|
||||
]
|
||||
|
||||
const timelineData = (report.timeline || []).map((t: any) => ({
|
||||
name: `${t.month}月`,
|
||||
risks: t.value,
|
||||
event: t.event,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">年度价值报告</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{year} 年度 · 系统价值量化分析</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||
className="px-2 py-1 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
|
||||
<option key={y} value={y}>{y} 年</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showHistory ? '收起' : '历史'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ROI 横幅 */}
|
||||
<Card className="bg-gradient-to-r from-primary/10 to-amber-50 border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
|
||||
<TrendingUp className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">投资回报率 (ROI)</div>
|
||||
<div className="text-3xl font-bold text-primary">{report.roi}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-500">年度总价值</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{fmtMoney(report.totalValue)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-700">{report.summary}</p>
|
||||
</Card>
|
||||
|
||||
{/* 4 核心指标 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{metricCards.map((m) => {
|
||||
const Icon = m.icon
|
||||
return (
|
||||
<Card key={m.label} className="flex items-center gap-2.5">
|
||||
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg}`}>
|
||||
<Icon className={`w-5 h-5 ${m.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-base font-bold ${m.color}`}>{m.value}</div>
|
||||
<div className="text-xs text-gray-500">{m.label}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 历史报告 */}
|
||||
{showHistory && (
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
历史年度报告
|
||||
</h2>
|
||||
{history && history.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{history.map((r: any) => (
|
||||
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{r.year} 年</span>
|
||||
<span className="font-bold text-primary">ROI {r.roi}%</span>
|
||||
<span className="text-gray-500">{r.summary}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">{new Date(r.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-3">暂无历史报告</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 月度风险趋势 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3">月度风险趋势</h2>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={timelineData}>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
formatter={(v: any) => [`${v} 项`, '风险']}
|
||||
contentStyle={{ fontSize: 12, borderRadius: 6, border: '1px solid #e5e7eb' }}
|
||||
/>
|
||||
<Bar dataKey="risks" radius={[4, 4, 0, 0]}>
|
||||
{timelineData.map((d: any, i: number) => (
|
||||
<Cell key={i} fill={d.risks > 5 ? '#EF4444' : d.risks > 0 ? '#F59E0B' : '#16A34A'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* 8 项运营统计 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3">年度运营统计</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{stats.map((s) => {
|
||||
const Icon = s.icon
|
||||
return (
|
||||
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
|
||||
<Icon className={`w-4 h-4 ${s.color}`} />
|
||||
<div>
|
||||
<div className="text-sm font-bold">{s.value}</div>
|
||||
<div className="text-xs text-gray-500">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 价值计算说明 */}
|
||||
<Card className="bg-gray-50">
|
||||
<h2 className="text-sm font-medium mb-2">价值计算说明</h2>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-safe mt-1 flex-shrink-0" />
|
||||
<span><b>规避损失</b>:已解决风险的预估损失金额之和(基于风险量化模型)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1 flex-shrink-0" />
|
||||
<span><b>节约工时</b>:AI 咨询 × 0.5h + 合同审查 × 1h + 算薪批次 × 2h + 制度公示 × 1h</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
|
||||
<span><b>节约成本</b>:节约工时 × 100 元/h(HR 平均时薪参考)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1 flex-shrink-0" />
|
||||
<span><b>总价值</b>:规避损失 + 节约成本</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-1 flex-shrink-0" />
|
||||
<span><b>ROI</b>:总价值 ÷ 系统年费(¥12,000)× 100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 用工体检诊断页面
|
||||
* 6 维度深度诊断 + 历史报告
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Stethoscope, Save, History, CheckCircle2, AlertCircle, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } from 'recharts'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
/**
|
||||
* 用工体检诊断页面
|
||||
*/
|
||||
export default function HealthCheck() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [expandedDim, setExpandedDim] = useState<string | null>(null)
|
||||
|
||||
const { data: healthCheck, isLoading } = useQuery<any>({
|
||||
queryKey: ['health-check'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/health-check') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: history } = useQuery<any>({
|
||||
queryKey: ['health-check-history'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/health-check/history') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showHistory,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/dashboard/health-check/save') as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('诊断报告已保存')
|
||||
queryClient.invalidateQueries({ queryKey: ['health-check-history'] })
|
||||
},
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
}
|
||||
|
||||
if (!healthCheck) return null
|
||||
|
||||
const levelConfig: Record<string, { color: string; bg: string; text: string; icon: typeof CheckCircle2; label: string }> = {
|
||||
safe: { color: '#16A34A', bg: 'bg-green-50', text: 'text-safe', icon: CheckCircle2, label: '健康' },
|
||||
warning: { color: '#D97706', bg: 'bg-amber-50', text: 'text-warning', icon: AlertCircle, label: '中等风险' },
|
||||
danger: { color: '#C00000', bg: 'bg-red-50', text: 'text-danger', icon: AlertTriangle, label: '高风险' },
|
||||
}
|
||||
const level = levelConfig[healthCheck.level] || levelConfig.warning
|
||||
const LevelIcon = level.icon
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">用工体检诊断</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{healthCheck.year} 年度 · 6 维度深度诊断</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showHistory ? '收起历史' : '历史报告'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
保存报告
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总评分 */}
|
||||
<Card className={level.bg}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-28 h-28 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
innerRadius="70%"
|
||||
outerRadius="100%"
|
||||
data={[{ value: healthCheck.totalScore, fill: level.color }]}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar background dataKey="value" cornerRadius={8} />
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-2xl font-bold ${level.text}`}>{healthCheck.totalScore}</span>
|
||||
<span className="text-xs text-gray-500">{level.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LevelIcon className={`w-4 h-4 ${level.text}`} />
|
||||
<span className="text-sm font-medium">诊断总结</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{healthCheck.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 历史报告 */}
|
||||
{showHistory && (
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<History className="w-4 h-4 text-gray-500" />
|
||||
历史诊断报告
|
||||
</h2>
|
||||
{history && history.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{history.map((r: any) => {
|
||||
const lc = levelConfig[r.level] || levelConfig.warning
|
||||
return (
|
||||
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{r.year} 年</span>
|
||||
<span className={`font-bold ${lc.text}`}>{r.totalScore} 分</span>
|
||||
<span className="text-gray-500">{r.summary}</span>
|
||||
</div>
|
||||
<span className="text-gray-400">
|
||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 text-center py-3">暂无历史报告</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 6 维度诊断 */}
|
||||
<div className="space-y-2">
|
||||
{healthCheck.dimensions.map((dim: any) => {
|
||||
const dimLevel = dim.score >= 85 ? 'safe' : dim.score >= 60 ? 'warning' : 'danger'
|
||||
const dc = levelConfig[dimLevel] || levelConfig.warning
|
||||
const DimIcon = dc.icon
|
||||
const isExpanded = expandedDim === dim.key
|
||||
return (
|
||||
<Card key={dim.key}>
|
||||
<button
|
||||
onClick={() => setExpandedDim(isExpanded ? null : dim.key)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${dc.bg}`}>
|
||||
<DimIcon className={`w-4 h-4 ${dc.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{dim.name}</div>
|
||||
<div className="text-xs text-gray-500">{dim.findings[0]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg font-bold ${dc.text}`}>{dim.score}</span>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t space-y-3">
|
||||
{/* 发现的问题 */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-700 mb-1">诊断发现</div>
|
||||
<div className="space-y-1">
|
||||
{dim.findings.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 建议 */}
|
||||
{dim.recommendations.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-primary mb-1">整改建议</div>
|
||||
<div className="space-y-1">
|
||||
{dim.recommendations.map((r: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 医疗期计算器
|
||||
* 根据员工工龄和地区计算法定医疗期天数
|
||||
* 法律依据:《企业职工患病或非因工负伤医疗期规定》(劳部发[1994]479号)
|
||||
* 上海特殊规定:沪府发[2015]40号
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Calculator, HeartPulse, Info } from 'lucide-react'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
interface MedicalPeriodResult {
|
||||
totalMonths: number
|
||||
cumulativeDays: number
|
||||
actualDays: number
|
||||
endDate: string
|
||||
legalBasis: string
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算医疗期
|
||||
* @param workYears 本单位工作年限
|
||||
* @param region 地区(上海/全国)
|
||||
* @param sickDays 累计病休天数
|
||||
* @param startDate 开始病休日期
|
||||
*/
|
||||
function calculateMedicalPeriod(
|
||||
workYears: number,
|
||||
region: 'shanghai' | 'national',
|
||||
sickDays: number,
|
||||
startDate: string,
|
||||
): MedicalPeriodResult | null {
|
||||
if (!startDate || workYears < 0) return null
|
||||
|
||||
let totalMonths: number
|
||||
let cumulativeDays: number
|
||||
let legalBasis: string
|
||||
const notes: string[] = []
|
||||
|
||||
if (region === 'shanghai') {
|
||||
// 上海特殊规定:直接按工龄分档
|
||||
if (workYears < 1) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30 // 6个月周期
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else if (workYears < 4) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else if (workYears < 10) {
|
||||
totalMonths = 6
|
||||
cumulativeDays = 12 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else {
|
||||
totalMonths = 9
|
||||
cumulativeDays = 18 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
}
|
||||
notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算')
|
||||
} else {
|
||||
// 全国通用规定:劳部发[1994]479号
|
||||
if (workYears < 5) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30 // 6个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 10) {
|
||||
totalMonths = 6
|
||||
cumulativeDays = 12 * 30 // 12个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 15) {
|
||||
totalMonths = 9
|
||||
cumulativeDays = 15 * 30 // 15个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 20) {
|
||||
totalMonths = 12
|
||||
cumulativeDays = 18 * 30 // 18个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else {
|
||||
totalMonths = 24
|
||||
cumulativeDays = 30 * 30 // 30个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
}
|
||||
notes.push(`在 ${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
|
||||
}
|
||||
|
||||
// 计算实际可用天数
|
||||
const actualDays = Math.max(0, totalMonths * 30 - sickDays)
|
||||
|
||||
// 计算医疗期结束日期
|
||||
const start = new Date(startDate)
|
||||
const endDate = new Date(start)
|
||||
endDate.setMonth(endDate.getMonth() + totalMonths)
|
||||
|
||||
notes.push('医疗期内企业不得解除劳动合同(法定情形除外)')
|
||||
notes.push('医疗期满后不能从事原工作也不能从事另行安排的工作,企业可提前30天通知或支付代通知金解除')
|
||||
|
||||
return {
|
||||
totalMonths,
|
||||
cumulativeDays,
|
||||
actualDays,
|
||||
endDate: endDate.toISOString().slice(0, 10),
|
||||
legalBasis,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 医疗期计算器页面
|
||||
*/
|
||||
export default function MedicalPeriodCalculator() {
|
||||
const [region, setRegion] = useState<'national' | 'shanghai'>('national')
|
||||
const [workYears, setWorkYears] = useState('')
|
||||
const [sickDays, setSickDays] = useState('0')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [result, setResult] = useState<MedicalPeriodResult | null>(null)
|
||||
|
||||
const handleCalculate = () => {
|
||||
const years = parseFloat(workYears) || 0
|
||||
const days = parseInt(sickDays) || 0
|
||||
const r = calculateMedicalPeriod(years, region, days, startDate)
|
||||
setResult(r)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setRegion('national')
|
||||
setWorkYears('')
|
||||
setSickDays('0')
|
||||
setStartDate('')
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">医疗期计算器</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
{/* 地区选择 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">所在地区</label>
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="national">全国(通用规定)</option>
|
||||
<option value="shanghai">上海(特殊规定)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 工龄输入 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">本单位工作年限(年)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={workYears}
|
||||
onChange={(e) => setWorkYears(e.target.value)}
|
||||
placeholder="如:5.5"
|
||||
step="0.5"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 病休开始日期 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">开始病休日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 累计病休天数 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">已累计病休天数</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sickDays}
|
||||
onChange={(e) => setSickDays(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCalculate} className="flex-1">
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
立即计算
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 计算结果 */}
|
||||
{result && (
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Info className="w-4 h-4 text-primary" />
|
||||
计算结果
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">法定医疗期</div>
|
||||
<div className="text-lg font-bold text-primary">{result.totalMonths} 个月</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">累计计算周期</div>
|
||||
<div className="text-lg font-bold text-primary">{result.cumulativeDays / 30} 个月</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">剩余可用天数</div>
|
||||
<div className={`text-lg font-bold ${result.actualDays > 0 ? 'text-safe' : 'text-danger'}`}>
|
||||
{result.actualDays} 天
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">医疗期截止日</div>
|
||||
<div className="text-sm font-bold text-gray-800">{result.endDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 法律依据 */}
|
||||
<div className="p-2 rounded-md bg-amber-50 text-xs text-amber-800">
|
||||
<span className="font-medium">法律依据:</span>{result.legalBasis}
|
||||
</div>
|
||||
|
||||
{/* 注意事项 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">注意事项</div>
|
||||
{result.notes.map((note, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 工龄分档表 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2">医疗期分档表(全国通用)</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 pr-3">工作年限</th>
|
||||
<th className="py-2 pr-3">医疗期</th>
|
||||
<th className="py-2 pr-3">累计周期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr><td className="py-2 pr-3">不满 5 年</td><td className="py-2 pr-3">3 个月</td><td className="py-2 pr-3">6 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">5-10 年</td><td className="py-2 pr-3">6 个月</td><td className="py-2 pr-3">12 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">10-15 年</td><td className="py-2 pr-3">9 个月</td><td className="py-2 pr-3">15 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">15-20 年</td><td className="py-2 pr-3">12 个月</td><td className="py-2 pr-3">18 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">20 年以上</td><td className="py-2 pr-3">24 个月</td><td className="py-2 pr-3">30 个月</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -83,13 +83,31 @@ export interface DashboardData {
|
||||
todoCount: number
|
||||
monthlyOvertimePay: number
|
||||
}
|
||||
urgentRisk: {
|
||||
id: string
|
||||
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
|
||||
level: 'high' | 'medium' | 'low'
|
||||
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
title: string
|
||||
description: string
|
||||
actionUrl: string
|
||||
estimatedLoss: number
|
||||
lossRange: [number, number] | null
|
||||
deadline: string | null
|
||||
daysUntilDeadline: number | null
|
||||
} | null
|
||||
todos: {
|
||||
id: string
|
||||
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
|
||||
level: 'high' | 'medium' | 'low'
|
||||
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
title: string
|
||||
description: string
|
||||
actionUrl: string
|
||||
estimatedLoss: number
|
||||
lossRange: [number, number] | null
|
||||
deadline: string | null
|
||||
daysUntilDeadline: number | null
|
||||
}[]
|
||||
resolvedTodos: {
|
||||
id: string
|
||||
@@ -105,13 +123,22 @@ export interface DashboardData {
|
||||
salary: number
|
||||
termination: number
|
||||
}
|
||||
riskTrend: {
|
||||
thisMonth: number
|
||||
lastMonth: number
|
||||
change: number
|
||||
}
|
||||
topRisks: {
|
||||
id: string
|
||||
type: string
|
||||
level: string
|
||||
priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
title: string
|
||||
description: string
|
||||
employeeName: string | null
|
||||
estimatedLoss: number
|
||||
deadline: string | null
|
||||
daysUntilDeadline: number | null
|
||||
actionUrl: string
|
||||
}[]
|
||||
aiPrediction: {
|
||||
|
||||
Reference in New Issue
Block a user