feat: 统一页面布局与字体样式
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `TerminationRecord` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "ContractType" ADD VALUE 'LABOR';
|
||||
ALTER TYPE "ContractType" ADD VALUE 'INTERNSHIP';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TerminationRecord" ADD COLUMN "approvalComment" TEXT,
|
||||
ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "approvedBy" TEXT,
|
||||
ADD COLUMN "checklistOverrides" JSONB,
|
||||
ADD COLUMN "compensationBreakdown" JSONB,
|
||||
ADD COLUMN "currentStep" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "handoverItems" JSONB,
|
||||
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
ADD COLUMN "updatedBy" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TerminationRecord_orgId_status_idx" ON "TerminationRecord"("orgId", "status");
|
||||
@@ -30,6 +30,8 @@ enum ContractType {
|
||||
FIXED
|
||||
UNFIXED
|
||||
UNSIGNED
|
||||
LABOR
|
||||
INTERNSHIP
|
||||
}
|
||||
|
||||
enum SignMethod {
|
||||
|
||||
+9
-1
@@ -9,7 +9,15 @@ import { apiLimiter } from './middleware/rateLimit'
|
||||
const app = express()
|
||||
|
||||
app.use(helmet())
|
||||
app.use(compression())
|
||||
app.use(compression({
|
||||
filter: (req, res) => {
|
||||
// SSE 流式响应不压缩,避免缓冲导致前端一次性收到所有数据
|
||||
if (req.headers['accept'] === 'text/event-stream' || res.getHeader('Content-Type') === 'text/event-stream') {
|
||||
return false
|
||||
}
|
||||
return compression.filter(req, res)
|
||||
},
|
||||
}))
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import app from './app'
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
const PORT = Number(process.env.PORT) || 3000
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
app.listen(PORT, '::', () => {
|
||||
console.log(`Server running on http://[::]:${PORT}`)
|
||||
})
|
||||
|
||||
@@ -109,12 +109,18 @@ router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next)
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
res.flushHeaders()
|
||||
let usageRecorded = false
|
||||
try {
|
||||
for await (const delta of chatStream(messages, orgContext)) {
|
||||
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
|
||||
if (typeof (res as any).flush === 'function') (res as any).flush()
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
} catch (streamErr: any) {
|
||||
res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`)
|
||||
res.write('data: [DONE]\n\n')
|
||||
} finally {
|
||||
if (!usageRecorded) {
|
||||
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
|
||||
|
||||
@@ -205,7 +205,7 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res,
|
||||
employee.contracts.forEach((c) => {
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: `劳动合同(${c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签订'})`,
|
||||
title: `合同(${({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record<string, string>)[c.contractType] || '未签订'})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。`,
|
||||
evidenceType: 'CONTRACT',
|
||||
@@ -788,4 +788,16 @@ router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res,
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 合同类型列表(供前端动态获取)
|
||||
router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => {
|
||||
const types = [
|
||||
{ value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true },
|
||||
{ value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false },
|
||||
{ value: 'LABOR', label: '劳务协议', hasEndDate: false },
|
||||
{ value: 'INTERNSHIP', label: '实习协议', hasEndDate: false },
|
||||
{ value: 'UNSIGNED', label: '未签合同', hasEndDate: false },
|
||||
]
|
||||
res.json({ success: true, data: types })
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -26,7 +26,14 @@ export function getContractStatus(contract: {
|
||||
hireDate: Date
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
FIXED: '固定期限',
|
||||
UNFIXED: '无固定期限',
|
||||
LABOR: '劳务协议',
|
||||
INTERNSHIP: '实习协议',
|
||||
UNSIGNED: '',
|
||||
}
|
||||
const typeLabel = typeLabelMap[contract.contractType] || ''
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
|
||||
Generated
+1497
-4
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,17 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"recharts": "^3.10.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
@@ -40,22 +40,22 @@ export default function Modal({ open, onClose, title, children, className, size
|
||||
'relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto transition-all duration-200',
|
||||
show ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
||||
{
|
||||
'max-w-md': size === 'sm',
|
||||
'max-w-lg': size === 'md',
|
||||
'max-w-2xl': size === 'lg',
|
||||
'max-w-4xl': size === 'xl',
|
||||
'max-w-xl': size === 'sm',
|
||||
'max-w-2xl': size === 'md',
|
||||
'max-w-3xl': size === 'lg',
|
||||
'max-w-6xl': size === 'xl',
|
||||
},
|
||||
className,
|
||||
)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900 text-base">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 p-1 rounded-md hover:bg-gray-100 transition-colors" aria-label="关闭">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">{children}</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 表单未保存时阻止页面离开
|
||||
* @param isDirty 表单是否有未保存的修改
|
||||
*/
|
||||
export function useUnsavedChanges(isDirty: boolean) {
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [isDirty])
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
const API_BASE = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: API_BASE,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
@@ -27,7 +29,7 @@ api.interceptors.response.use(
|
||||
try {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
if (!refreshToken) throw new Error('No refresh token')
|
||||
const res = await axios.post('/api/v1/auth/refresh', { refreshToken })
|
||||
const res = await axios.post(`${API_BASE}/auth/refresh`, { refreshToken })
|
||||
const newToken = res.data.data.accessToken
|
||||
useAuthStore.getState().updateToken(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -36,7 +38,13 @@ export default function AIAssistant() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xs font-medium">AI 合规顾问</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">AI 合规顾问</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">智能分析用工风险,辅助合同审查与合规决策</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
{tabs.map((t) => {
|
||||
@@ -45,7 +53,7 @@ export default function AIAssistant() {
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -170,8 +178,9 @@ function ChatTab() {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60 * 1000)
|
||||
const chatUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1/ai/chat-stream' : '/api/v1/ai/chat-stream'
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -191,6 +200,21 @@ function ChatTab() {
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
let buffer = ''
|
||||
let rafId: number | null = null
|
||||
let pendingFlush = false
|
||||
|
||||
// 用 RAF 批量刷新,避免每个 token 触发一次 React 重渲染
|
||||
const flush = () => {
|
||||
pendingFlush = false
|
||||
rafId = null
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
const scheduleFlush = () => {
|
||||
if (!pendingFlush) {
|
||||
pendingFlush = true
|
||||
rafId = requestAnimationFrame(flush)
|
||||
}
|
||||
}
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
@@ -207,14 +231,27 @@ function ChatTab() {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.delta) {
|
||||
accumulated += parsed.delta
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
scheduleFlush()
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
} catch (parseErr: any) {
|
||||
// 只有业务错误(有 message 且不是 SyntaxError)才抛出
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
// JSON 解析失败,可能是 SSE 分块截断,跳过等下一块
|
||||
continue
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 确保最后一批内容被刷新
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
}
|
||||
if (!accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||
@@ -254,10 +291,40 @@ function ChatTab() {
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
|
||||
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
|
||||
<div className={`max-w-[85%] px-4 py-3 rounded-lg text-sm leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-primary text-white whitespace-pre-wrap' : 'bg-white border border-gray-200 text-gray-800 shadow-sm'
|
||||
}`}>
|
||||
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
|
||||
{msg.role === 'assistant' ? (
|
||||
msg.content ? (
|
||||
<div className="prose prose-sm max-w-none
|
||||
prose-headings:text-gray-900 prose-headings:font-semibold
|
||||
prose-h1:text-base prose-h1:mt-4 prose-h1:mb-2
|
||||
prose-h2:text-sm prose-h2:mt-3 prose-h2:mb-2
|
||||
prose-h3:text-sm prose-h3:mt-2 prose-h3:mb-1
|
||||
prose-p:my-2 prose-p:leading-relaxed
|
||||
prose-li:my-0.5 prose-li:leading-relaxed
|
||||
prose-ul:my-2 prose-ol:my-2
|
||||
prose-code:text-pink-600 prose-code:bg-gray-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||
prose-pre:bg-gray-900 prose-pre:text-gray-100 prose-pre:rounded-lg prose-pre:p-3 prose-pre:my-3
|
||||
prose-blockquote:border-l-primary prose-blockquote:bg-primary/5 prose-blockquote:py-1 prose-blockquote:px-3 prose-blockquote:rounded-r prose-blockquote:not-italic
|
||||
prose-table:text-xs prose-table:border-collapse
|
||||
prose-th:bg-gray-50 prose-th:px-3 prose-th:py-1.5 prose-th:font-semibold prose-th:border prose-th:border-gray-200
|
||||
prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-gray-200
|
||||
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
|
||||
prose-strong:text-gray-900
|
||||
prose-hr:border-gray-200 prose-hr:my-4
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : loading && i === messages.length - 1 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-500">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
思考中...
|
||||
</span>
|
||||
) : null
|
||||
) : (
|
||||
msg.content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -62,14 +62,20 @@ export default function Compensation() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">补偿计算</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">补偿计算</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">根据解除原因快速测算经济补偿与双倍工资</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Paperclip, Trash2, X } from 'lucide-react'
|
||||
import { Plus, Search, Paperclip, Trash2, X, FileText } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -57,7 +57,13 @@ export default function Contracts() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">合同管理</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">合同管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">集中管理劳动合同、签署状态及到期风险</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||||
</Button>
|
||||
@@ -90,9 +96,9 @@ export default function Contracts() {
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
<th className="py-2 px-3 font-medium">部门</th>
|
||||
<th className="py-2 px-3 font-medium">入职日期</th>
|
||||
|
||||
@@ -158,8 +158,11 @@ export default function Dashboard() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xs font-medium">{data.greeting}</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} 月度总览</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">工作台</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{data.greeting} · {payroll?.month} 月度总览</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
@@ -175,7 +178,7 @@ export default function Dashboard() {
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -29,7 +29,10 @@ export default function Money() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及加班薪酬</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto">
|
||||
@@ -644,9 +647,9 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<Pagination page={page} pageSize={pageSize} total={batch.entries.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 px-2">员工</th>
|
||||
<th className="py-2 px-2 text-right">基本工资</th>
|
||||
<th className="py-2 px-2 text-right">加班费</th>
|
||||
@@ -875,9 +878,9 @@ function TemplateManager() {
|
||||
<div className="text-center py-4 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 px-2">序号</th>
|
||||
<th className="py-2 px-2">名称</th>
|
||||
<th className="py-2 px-2">字段代码</th>
|
||||
@@ -1268,9 +1271,9 @@ function OvertimeCalculator() {
|
||||
<div className="border-t pt-3 space-y-3">
|
||||
<div className="text-xs font-medium text-gray-700">预览({previewData.length}条)</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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-right">工作日(h)</th>
|
||||
@@ -1324,9 +1327,9 @@ function OvertimeCalculator() {
|
||||
<div className="text-center py-8 text-gray-500">该月暂无加班记录,请先导入考勤数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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-right">工作日(h)</th>
|
||||
@@ -1535,9 +1538,9 @@ function PayslipManager() {
|
||||
<Card>
|
||||
<Pagination page={page} pageSize={pageSize} total={payslips.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 px-2">员工</th>
|
||||
<th className="py-2 px-2">部门</th>
|
||||
<th className="py-2 px-2 text-right">基本工资</th>
|
||||
|
||||
+253
-156
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus } from 'lucide-react'
|
||||
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 } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import api from '../lib/api'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -219,20 +220,26 @@ export default function Roster() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xs font-medium">花名册</h1>
|
||||
<div className="flex gap-2 flex-nowrap items-center">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users 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 flex-wrap items-center justify-start gap-2 xl:justify-end">
|
||||
<Input
|
||||
placeholder="搜索姓名/部门"
|
||||
placeholder="搜索姓名或部门"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="!w-64 shrink-0"
|
||||
className="!w-full sm:!w-64 shrink-0"
|
||||
/>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">在职</option>
|
||||
@@ -242,7 +249,7 @@ export default function Roster() {
|
||||
<select
|
||||
value={filterContractStatus}
|
||||
onChange={(e) => { setFilterContractStatus(e.target.value); setPage(1) }}
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
>
|
||||
<option value="">全部合同</option>
|
||||
<option value="active">正常</option>
|
||||
@@ -253,73 +260,80 @@ export default function Roster() {
|
||||
<option value="unsigned_over_year">未签合同(已满一年)</option>
|
||||
</select>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-400 hover:text-gray-600">清除</button>
|
||||
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700">清除筛选</button>
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
||||
<Check className="w-4 h-4 mr-1" />批量续签({selectedIds.size})
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
|
||||
<UserX className="w-4 h-4 mr-1" />批量解聘({selectedIds.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setShowAddModal(true)} className="shrink-0">
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||||
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<span className="text-sm font-medium text-gray-700">已选择 {selectedIds.size} 名员工</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
||||
<Check className="mr-1 h-4 w-4" />批量续签
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
|
||||
<UserX className="mr-1 h-4 w-4" />批量解聘
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">加载中...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无员工</div></Card>
|
||||
<Card><div className="py-12 text-center text-sm text-gray-400">暂无符合条件的员工</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="border-b border-gray-100 px-5">
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8">
|
||||
<table className="w-full min-w-[1200px] text-sm">
|
||||
<thead className="bg-gray-50/90">
|
||||
<tr className="border-b border-gray-200 text-xs font-medium text-gray-500">
|
||||
<th className="px-4 py-3 text-left w-8">
|
||||
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
<th className="py-2 px-3 text-left">姓名</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">状态</th>
|
||||
<th className="py-2 px-3 text-left">入职日期</th>
|
||||
<th className="py-2 px-3 text-left">离职日期</th>
|
||||
<th className="py-2 px-3 text-right">月薪</th>
|
||||
<th className="py-2 px-3 text-left">合同状态</th>
|
||||
<th className="py-2 px-3 text-left">合同到期</th>
|
||||
<th className="py-2 px-3 text-center">违纪</th>
|
||||
<th className="py-2 px-3 text-center">考勤</th>
|
||||
<th className="py-2 px-3 text-center">培训</th>
|
||||
<th className="py-2 px-3 text-center">绩效</th>
|
||||
<th className="py-2 px-3 text-center">工资条</th>
|
||||
<th className="py-2 px-3 text-center">操作</th>
|
||||
<th className="px-4 py-3 text-left">姓名</th>
|
||||
<th className="px-4 py-3 text-left">部门</th>
|
||||
<th className="px-4 py-3 text-left">状态</th>
|
||||
<th className="hidden px-4 py-3 text-left">入职日期</th>
|
||||
<th className="hidden px-4 py-3 text-left">离职日期</th>
|
||||
<th className="px-4 py-3 text-right">月薪</th>
|
||||
<th className="px-4 py-3 text-left">合同类型</th>
|
||||
<th className="px-4 py-3 text-left">合同状态</th>
|
||||
<th className="hidden px-4 py-3 text-left">合同到期</th>
|
||||
<th className="px-4 py-3 text-center">违纪</th>
|
||||
<th className="px-4 py-3 text-center">考勤</th>
|
||||
<th className="px-4 py-3 text-center">培训</th>
|
||||
<th className="px-4 py-3 text-center">绩效</th>
|
||||
<th className="px-4 py-3 text-center">工资条</th>
|
||||
<th className="px-4 py-3 text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((e: any) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
|
||||
className="border-b border-gray-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03]"
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
>
|
||||
<td className="py-2 px-3" onClick={(ev) => ev.stopPropagation()}>
|
||||
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||
</td>
|
||||
<td className="py-2 px-3 font-medium">{e.name}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{e.department}</td>
|
||||
<td className="py-2 px-3">
|
||||
<td className="px-4 py-3 font-medium">{e.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{e.department}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
||||
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
||||
@@ -328,8 +342,8 @@ export default function Roster() {
|
||||
{e.status === 'ACTIVE' ? '在职' : e.status === 'PRE_HIRE' ? '预入职' : '离职'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
<td className="py-2 px-3 text-gray-500">
|
||||
<td className="hidden px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
{e.hasTermination && e.latestTerminationDate ? (
|
||||
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
||||
{e.latestTerminationDate.toString().slice(0, 10)}
|
||||
@@ -339,8 +353,22 @@ export default function Roster() {
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<td className="px-4 py-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const typeConfig: Record<string, { label: string; style: string }> = {
|
||||
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
||||
UNFIXED: { label: '劳动合同-无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' },
|
||||
LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' },
|
||||
INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' },
|
||||
UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' },
|
||||
}
|
||||
const ct = e.latestContract?.contractType
|
||||
const cfg = typeConfig[ct] || typeConfig.UNSIGNED
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const tagStyles: Record<string, string> = {
|
||||
expired: 'bg-red-50 text-danger',
|
||||
@@ -349,68 +377,92 @@ export default function Roster() {
|
||||
unsigned: 'bg-yellow-50 text-yellow-700',
|
||||
expiring: 'bg-yellow-50 text-yellow-700',
|
||||
active: 'bg-green-50 text-safe',
|
||||
unfixed: 'bg-blue-50 text-blue-700',
|
||||
unfixed: 'bg-green-50 text-safe',
|
||||
}
|
||||
const statusTextMap: Record<string, string> = {
|
||||
expired: '已过期',
|
||||
unsigned_over_year: '未签署(超1年)',
|
||||
unsigned_over_30: '未签署(超30天)',
|
||||
unsigned: '未签署',
|
||||
expiring: '即将到期',
|
||||
active: '正常',
|
||||
unfixed: '正常',
|
||||
}
|
||||
const style = tagStyles[e.contractStatus] || 'bg-gray-100 text-gray-500'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{e.contractStatusText || '无合同'}</span>
|
||||
const text = statusTextMap[e.contractStatus] || '无合同'
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
{(() => {
|
||||
const endDate = e.latestContract?.endDate
|
||||
if (!endDate) return <span className="text-gray-300">—</span>
|
||||
if (!endDate) {
|
||||
// 无固定期限合同显示"无固定期限",否则显示"—"
|
||||
if (e.latestContract?.contractType === 'UNFIXED') return <span className="text-xs text-gray-400">无固定期限</span>
|
||||
return <span className="text-gray-300">—</span>
|
||||
}
|
||||
const end = new Date(endDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 0) return <span className="text-danger text-xs">已过期</span>
|
||||
if (diffDays <= 30) return <span className="text-danger font-medium text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays}天)</span>
|
||||
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays}天)</span>
|
||||
return <span className="text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
|
||||
const dateStr = end.toISOString().slice(0, 10)
|
||||
if (diffDays < 0) return <span className="text-danger text-xs">{dateStr} (已过期)</span>
|
||||
if (diffDays <= 30) return <span className="text-danger font-medium text-xs">{dateStr} ({diffDays}天)</span>
|
||||
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{dateStr} ({diffDays}天)</span>
|
||||
return <span className="text-xs">{dateStr}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<td className="px-4 py-3 text-center">
|
||||
{e.counts?.disciplinaryRecords ? (
|
||||
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
|
||||
) : <span className="text-gray-300">0</span>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
|
||||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
|
||||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
|
||||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{e.status === 'ACTIVE' && !e.hasTermination && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
className="text-xs text-gray-500 hover:text-primary"
|
||||
type="button"
|
||||
title="调薪"
|
||||
aria-label={`为${e.name}调薪`}
|
||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setSalaryEmployee(e)
|
||||
setShowSalaryModal(true)
|
||||
}}
|
||||
>
|
||||
调薪
|
||||
<DollarSign className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-gray-500 hover:text-primary"
|
||||
type="button"
|
||||
title="调部门"
|
||||
aria-label={`为${e.name}调整部门`}
|
||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setDeptEmployee(e)
|
||||
setShowDeptModal(true)
|
||||
}}
|
||||
>
|
||||
调部门
|
||||
<Building2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-gray-500 hover:text-danger flex items-center gap-0.5"
|
||||
type="button"
|
||||
title="离职"
|
||||
aria-label={`为${e.name}办理离职`}
|
||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setResignEmployee(e)
|
||||
setShowResignModal(true)
|
||||
}}
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" />离职
|
||||
<UserX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -420,7 +472,10 @@ export default function Roster() {
|
||||
{e.latestTerminationType === 'RESIGNATION' ? '待离职' : '待解聘'}
|
||||
</span>
|
||||
<button
|
||||
className="text-xs text-gray-400 hover:text-danger"
|
||||
type="button"
|
||||
title="撤回"
|
||||
aria-label={`撤回${e.name}的${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录`}
|
||||
className="rounded-md p-1.5 text-gray-400 transition hover:bg-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.latestTerminationId && window.confirm(`确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`)) {
|
||||
@@ -428,20 +483,23 @@ export default function Roster() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
撤回
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{e.status === 'RESIGNED' && (
|
||||
<button
|
||||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-0.5"
|
||||
type="button"
|
||||
title="重新入职"
|
||||
aria-label={`为${e.name}办理重新入职`}
|
||||
className="rounded-md p-1.5 text-primary transition hover:bg-primary/10 hover:text-primary/80"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setRehireEmployee(e)
|
||||
setShowRehireModal(true)
|
||||
}}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />重新入职
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -747,7 +805,7 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -1446,6 +1504,14 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
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)
|
||||
@@ -1591,7 +1657,7 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
<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 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="默认为月工资" />
|
||||
@@ -1611,39 +1677,44 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
|
||||
<option value="FIXED">固定期限</option>
|
||||
<option value="UNFIXED">无固定期限</option>
|
||||
<option value="UNSIGNED">暂不签订</option>
|
||||
</Select>
|
||||
<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-2 gap-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="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>签约时长(年)</Label>
|
||||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
</>
|
||||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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} />
|
||||
@@ -1691,6 +1762,14 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
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)
|
||||
@@ -1823,34 +1902,40 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
&& (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="添加员工">
|
||||
<div className="space-y-3">
|
||||
<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-xs">
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">
|
||||
{error.response?.data?.error?.message || '操作失败'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 基本信息 */}
|
||||
<div 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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位身份证号" maxLength={18} /></div>
|
||||
<div><Label>性别</Label><div className="text-xs text-gray-600 py-1.5">{form.idCardNumber.length >= 17 ? form.gender : '由身份证号自动识别'}</div></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>参保城市</Label><select className="w-full text-xs border rounded px-2 py-1.5" 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 pt-3">
|
||||
<Label>社保公积金</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与月工资一致,可手动修改</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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="默认为月工资" />
|
||||
@@ -1869,58 +1954,70 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
|
||||
<option value="FIXED">固定期限</option><option value="UNFIXED">无固定期限</option><option value="UNSIGNED">未签合同</option>
|
||||
</Select>
|
||||
{/* 合同信息 */}
|
||||
<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-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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-400 mt-0.5">留空表示尚未签订</div>
|
||||
<div className="text-xs text-gray-500 mt-1">留空表示尚未签订</div>
|
||||
</div>
|
||||
<div><Label>合同开始日期</Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</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="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>签约时长(年)</Label>
|
||||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>合同结束日期</Label>
|
||||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
</>
|
||||
<div className="text-xs text-gray-500">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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-400 mt-0.5">法定上限:{probationMax}个月</div>
|
||||
<div className="text-xs text-gray-500 mt-1">法定上限:{probationMax}个月</div>
|
||||
)}
|
||||
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</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-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
<div className="text-xs text-gray-500 mt-1">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||||
)}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||||
{probationSalaryError && <div className="text-xs text-danger mt-1">{probationSalaryError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<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>
|
||||
@@ -2040,9 +2137,9 @@ function PayslipInfo({ payslips }: { payslips: any[] }) {
|
||||
const totalPay = payslips.reduce((s, p) => s + (p.totalPay || 0), 0)
|
||||
return (
|
||||
<Card>
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
@@ -2093,9 +2190,9 @@ function OvertimeInfo({ records }: { records: any[] }) {
|
||||
const totalHoliday = records.reduce((sum, o) => sum + (o.holidayHours || 0), 0)
|
||||
return (
|
||||
<Card>
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
@@ -2474,9 +2571,9 @@ function AttendanceInfo({ employeeId, records }: { employeeId: string; records:
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无考勤记录</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
|
||||
@@ -44,7 +44,13 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xs font-medium">系统设置</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">系统设置</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">配置企业资料、用户权限、套餐与通知偏好</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{sections.map((s) => {
|
||||
@@ -53,7 +59,7 @@ export default function Settings() {
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setActiveSection(s.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeSection === s.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
@@ -158,9 +164,9 @@ function UserSettings({ usersData }: { usersData: any }) {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 px-3 font-medium">姓名</th>
|
||||
<th className="py-2 px-3 font-medium">手机号</th>
|
||||
<th className="py-2 px-3 font-medium">角色</th>
|
||||
@@ -652,13 +658,13 @@ function ImportSettings() {
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setImportType('init')}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
初始化导入
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportType('monthly')}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
月度导入
|
||||
</button>
|
||||
@@ -830,9 +836,9 @@ function InitImport() {
|
||||
})}
|
||||
</div>
|
||||
<div className="overflow-x-auto max-h-60 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-1 px-2">行号</th>
|
||||
<th className="py-1 px-2">姓名</th>
|
||||
<th className="py-1 px-2">状态</th>
|
||||
|
||||
@@ -232,7 +232,13 @@ export default function SocialInsurance() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xs font-medium">社保公积金</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">社保公积金</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">维护社保与公积金缴费基数、版本及月度记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{tab !== 'monthly' && (
|
||||
<>
|
||||
@@ -253,7 +259,7 @@ export default function SocialInsurance() {
|
||||
{(['social', 'housing', 'monthly'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
|
||||
@@ -262,9 +268,9 @@ export default function SocialInsurance() {
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-xs text-gray-500">城市:</label>
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<select
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
@@ -286,8 +292,8 @@ export default function SocialInsurance() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">当前生效</span>
|
||||
<span className="text-xs text-gray-500">生效月份:{activeConfig.effectiveFrom}</span>
|
||||
<span className="text-xs text-gray-500">· {activeConfig.city}</span>
|
||||
<span className="text-sm text-gray-500">生效月份:{activeConfig.effectiveFrom}</span>
|
||||
<span className="text-sm text-gray-500">· {activeConfig.city}</span>
|
||||
{activeConfig.adjustmentDone && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已调整员工基数</span>
|
||||
)}
|
||||
@@ -320,14 +326,14 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(企业)</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(个人)</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-xs">
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
|
||||
@@ -346,7 +352,7 @@ export default function SocialInsurance() {
|
||||
{/* 调整预览 */}
|
||||
{tab !== 'monthly' && showAdjust && adjustData && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />员工{isHousing ? '公积金' : '社保'}基数调整
|
||||
</h3>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
|
||||
@@ -374,9 +380,9 @@ export default function SocialInsurance() {
|
||||
<span className="text-xs text-gray-400">共 {adjustData.total} 名员工</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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-right">上年月均</th>
|
||||
@@ -437,9 +443,9 @@ export default function SocialInsurance() {
|
||||
<div className="text-center py-4 text-gray-400 text-xs">暂无版本记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
@@ -530,7 +536,7 @@ export default function SocialInsurance() {
|
||||
{tab !== 'monthly' && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="text-xs font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
@@ -541,7 +547,7 @@ export default function SocialInsurance() {
|
||||
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{activeConfig && (
|
||||
<div className="text-xs text-gray-400">
|
||||
<div className="text-sm text-gray-400">
|
||||
当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
|
||||
</div>
|
||||
)}
|
||||
@@ -549,22 +555,22 @@ export default function SocialInsurance() {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
{(() => {
|
||||
const r = isHousing ? housingResult : result
|
||||
if (!r) return <div className="text-gray-400 text-xs">点击「开始计算」查看结果</div>
|
||||
if (!r) return <div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
@@ -612,7 +618,7 @@ export default function SocialInsurance() {
|
||||
{tab === 'monthly' && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-medium">月度办理</h2>
|
||||
<h2 className="text-sm font-medium">月度办理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="month" value={monthlyMonth} onChange={(e) => setMonthlyMonth(e.target.value)} className="!w-32" />
|
||||
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('social', monthlyChanges.social)}>
|
||||
@@ -623,11 +629,11 @@ export default function SocialInsurance() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md mb-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-sm px-3 py-2 rounded-md mb-3">
|
||||
展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。
|
||||
</div>
|
||||
{(() => {
|
||||
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-xs">加载中...</div>
|
||||
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-sm">加载中...</div>
|
||||
const sAdd = monthlyChanges.social?.additions || []
|
||||
const sSub = monthlyChanges.social?.subtractions || []
|
||||
const sNormal = monthlyChanges.socialActive?.items || []
|
||||
@@ -635,17 +641,17 @@ export default function SocialInsurance() {
|
||||
const hSub = monthlyChanges.housing?.subtractions || []
|
||||
const hNormal = monthlyChanges.housingActive?.items || []
|
||||
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) {
|
||||
return <div className="text-center py-4 text-gray-400 text-xs">{monthlyMonth} 无办理记录</div>
|
||||
return <div className="text-center py-4 text-gray-400 text-sm">{monthlyMonth} 无办理记录</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 社保 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium mb-2">社保</h3>
|
||||
<h3 className="text-sm font-medium mb-2">社保</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
@@ -691,11 +697,11 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
{/* 公积金 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium mb-2">公积金</h3>
|
||||
<h3 className="text-sm font-medium mb-2">公积金</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<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>
|
||||
@@ -745,7 +751,7 @@ export default function SocialInsurance() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
|
||||
@@ -593,7 +593,13 @@ export default function Termination() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xs font-medium">解聘补偿</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">解聘补偿</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">规范处理离职、解聘审批及补偿核算流程</p>
|
||||
</div>
|
||||
</div>
|
||||
{view === 'list' && (
|
||||
<Button size="sm" onClick={handleNewTermination}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建解聘
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,3 +1,5 @@
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
@@ -19,5 +21,5 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [typography],
|
||||
}
|
||||
|
||||
@@ -10,11 +10,5 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,11 +11,5 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user