feat: 统一页面布局与字体样式

This commit is contained in:
selfrelease
2026-07-24 17:30:36 +08:00
parent 4ae24990a7
commit a28b065ab8
25 changed files with 2032 additions and 259 deletions
@@ -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");
+2
View File
@@ -30,6 +30,8 @@ enum ContractType {
FIXED
UNFIXED
UNSIGNED
LABOR
INTERNSHIP
}
enum SignMethod {
+9 -1
View File
@@ -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',
+3 -3
View File
@@ -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}`)
})
+6
View File
@@ -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')
+13 -1
View File
@@ -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
+8 -1
View File
@@ -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)
+1497 -4
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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",
+8 -8
View File
@@ -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>
)
+18
View File
@@ -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])
}
+4 -2
View File
@@ -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}`
+77 -10
View File
@@ -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>
))}
+8 -2
View File
@@ -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'
}`}
>
+10 -4
View File
@@ -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>
+6 -3
View File
@@ -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'
}`}
>
+14 -11
View File
@@ -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
View File
@@ -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>
+14 -8
View File
@@ -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>
+37 -31
View File
@@ -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>
+7 -1
View File
@@ -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" />
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3 -1
View File
@@ -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],
}
-6
View File
@@ -10,11 +10,5 @@ export default defineConfig({
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});
-6
View File
@@ -11,11 +11,5 @@ export default defineConfig({
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})