fix: HR系统优化批次1 - P0/P1问题修复

P0-3: 修复合同状态判断逻辑,有合同记录但signDate为null时不再误判未签
P0-5: 修复参保城市默认北京问题,导入和预览均改为null
P0-11: 添加全局ErrorBoundary防止白屏,三处布局均包裹
P1-2: 合同附件改为可选,允许先保存再补充上传
P1-7.2: 排班弹窗增加员工搜索(姓名/部门)
P1-8.2: 加班费导入支持Excel(xlsx/xls)格式,兼容中英文列名
P1-9: 社保/公积金基数月度办理支持逐人修改,后端返回recordId
This commit is contained in:
selfrelease
2026-07-30 18:34:36 +08:00
parent 411adb7ddc
commit 55286819ae
11 changed files with 480 additions and 39 deletions
+13 -4
View File
@@ -8,6 +8,7 @@ import MobileTabBar from './components/layout/MobileTabBar'
import PortalLayout from './components/layout/PortalLayout'
import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton'
import ErrorBoundary from './components/ui/ErrorBoundary'
const Login = lazy(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register'))
@@ -68,7 +69,9 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
<TopNav onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 py-6 pb-20 md:pb-6">
<PageContainer>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
<ErrorBoundary>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</ErrorBoundary>
</PageContainer>
</main>
<MobileTabBar />
@@ -103,7 +106,9 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
</header>
<main className="flex-1 py-6 px-4 md:px-6">
<div className="max-w-7xl mx-auto">
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
<ErrorBoundary>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
@@ -116,14 +121,18 @@ function PortalLayoutWrapper({ children, showNav = true }: { children: React.Rea
return (
<div className="min-h-screen bg-surface pt-safe pb-safe">
<main className="max-w-md mx-auto py-6 px-4">
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
<ErrorBoundary>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</ErrorBoundary>
</main>
</div>
)
}
return (
<PortalLayout>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
<ErrorBoundary>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</ErrorBoundary>
</PortalLayout>
)
}
@@ -0,0 +1,71 @@
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
/**
* 全局错误边界组件
* 捕获子组件渲染异常,显示降级 UI 而非白屏
*/
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
handleReload = () => {
window.location.reload()
}
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-6 text-center">
<div className="w-16 h-16 rounded-full bg-red-50 flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<h2 className="text-base font-semibold text-gray-900 mb-1"></h2>
<p className="text-sm text-gray-500 mb-4 max-w-md">
{this.state.error?.message || '页面渲染过程中发生异常,请尝试刷新或返回重试'}
</p>
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={this.handleReload}
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
</button>
</div>
</div>
)
}
return this.props.children
}
}
+13 -1
View File
@@ -468,6 +468,7 @@ function ScheduleTab() {
const [showAssign, setShowAssign] = useState(false)
const [selectedShiftId, setSelectedShiftId] = useState('')
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const { data: shifts } = useQuery<any>({
queryKey: ['shifts'],
@@ -602,8 +603,19 @@ function ScheduleTab() {
</div>
<div>
<Label>{selectedEmployeeIds.size} </Label>
<input
type="text"
placeholder="搜索员工姓名或部门..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 mb-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div className="max-h-60 overflow-y-auto border rounded-lg divide-y">
{employees.map((emp: any) => (
{employees.filter((emp: any) => {
if (!searchQuery.trim()) return true
const q = searchQuery.trim().toLowerCase()
return emp.name?.toLowerCase().includes(q) || emp.department?.toLowerCase().includes(q)
}).map((emp: any) => (
<label key={emp.employeeId} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedEmployeeIds.has(emp.employeeId)} onChange={() => toggleEmployee(emp.employeeId)} />
<span className="text-sm">{emp.name}</span>
+51 -14
View File
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import * as XLSX from 'xlsx'
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
@@ -1527,34 +1528,70 @@ function OvertimeCalculator() {
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
const lines = text.split('\n').filter(l => l.trim())
const empList = employees?.items || []
const fileName = file.name.toLowerCase()
const parseRows = (rows: any[]): void => {
const items: any[] = []
const empList = employees?.items || []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim())
const empName = cols[0]
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
// 兼容中文列名和英文列名
const empName = String(row['姓名'] ?? row['name'] ?? row['姓名*'] ?? '').trim()
if (!empName) continue
const emp = empList.find(e => e.name === empName)
if (!emp) continue
items.push({
employeeId: emp.id,
employeeName: emp.name,
department: emp.department,
month: cols[4] || month,
weekdayHours: Number(cols[1]) || 0,
weekendHours: Number(cols[2]) || 0,
holidayHours: Number(cols[3]) || 0,
month: String(row['月份'] ?? row['month'] ?? '').trim() || month,
weekdayHours: Number(row['工作日加班时长'] ?? row['weekdayHours'] ?? row['工作日'] ?? 0) || 0,
weekendHours: Number(row['休息日加班时长'] ?? row['weekendHours'] ?? row['休息日'] ?? 0) || 0,
holidayHours: Number(row['法定节假日加班时长'] ?? row['holidayHours'] ?? row['法定节假日'] ?? 0) || 0,
})
}
if (items.length > 0) {
setPreviewData(items)
} else {
toast.error('未匹配到员工,请确保CSV第一列为员工姓名')
toast.error('未匹配到员工,请确保文件包含"姓名"列')
}
}
reader.readAsText(file)
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
// Excel 格式解析
const reader = new FileReader()
reader.onload = (event) => {
try {
const data = new Uint8Array(event.target?.result as ArrayBuffer)
const wb = XLSX.read(data, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const rows = XLSX.utils.sheet_to_json(ws)
parseRows(rows)
} catch {
toast.error('Excel 文件解析失败')
}
}
reader.readAsArrayBuffer(file)
} else {
// CSV 格式解析(保持兼容)
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
const lines = text.split('\n').filter(l => l.trim())
if (lines.length < 2) { toast.error('CSV 文件内容为空'); return }
// 解析表头
const headers = lines[0].split(',').map(c => c.trim())
const rows: any[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim())
const row: any = {}
headers.forEach((h, idx) => { row[h] = cols[idx] ?? '' })
rows.push(row)
}
parseRows(rows)
}
reader.readAsText(file)
}
}
const confirmImport = () => {
+90 -12
View File
@@ -1048,9 +1048,9 @@ export default function SocialInsurance() {
</tr>
</thead>
<tbody>
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" />)}
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" />)}
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" />)}
{add.map((i: any) => <MonthlyRow key={`sa-${city}-${i.employeeId}`} item={i} type="add" onCorrected={handleMonthlyProcess} />)}
{sub.map((i: any) => <MonthlyRow key={`ss-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
{normal.map((i: any) => <MonthlyRow key={`sn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
</tbody>
{(add.length > 0 || normal.length > 0) && (
<tfoot>
@@ -1090,9 +1090,9 @@ export default function SocialInsurance() {
</tr>
</thead>
<tbody>
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" />)}
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" />)}
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" />)}
{add.map((i: any) => <MonthlyHousingRow key={`ha-${city}-${i.employeeId}`} item={i} type="add" onCorrected={handleMonthlyProcess} />)}
{sub.map((i: any) => <MonthlyHousingRow key={`hs-${city}-${i.employeeId}`} item={i} type="sub" onCorrected={handleMonthlyProcess} />)}
{normal.map((i: any) => <MonthlyHousingRow key={`hn-${city}-${i.employeeId}`} item={i} type="normal" onCorrected={handleMonthlyProcess} />)}
</tbody>
{(add.length > 0 || normal.length > 0) && (
<tfoot>
@@ -1146,19 +1146,58 @@ export default function SocialInsurance() {
)
}
/** 月度办理社保行组件(可展开查看各险种明细) */
function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
/** 月度办理社保行组件(可展开查看各险种明细,支持修改基数 */
function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => api.put(`/social/records/social/${i.recordId}/correct`, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<>
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setExpanded(!expanded)}>
<td className="py-1.5">{i.name} {d && <span className="text-gray-300 text-xs">{expanded ? '▾' : '▸'}</span>}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-right">
{editing ? (
<span onClick={(e) => e.stopPropagation()} className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={(e) => { e.stopPropagation(); setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.totalOrg)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.totalEmp)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
@@ -1196,17 +1235,56 @@ function MonthlyRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'norma
)
}
/** 月度办理公积金行组件 */
function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' | 'normal' }) {
/** 月度办理公积金行组件(支持修改基数) */
function MonthlyHousingRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) {
const [editing, setEditing] = useState(false)
const [editBase, setEditBase] = useState(i.base?.toString() || '')
const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常'
const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500'
const d = i.detail
const correctMutation = useMutation({
mutationFn: (data: { base: number }) => api.put(`/social/records/housing/${i.recordId}/correct`, data),
onSuccess: () => {
setEditing(false)
toast.success('基数已修改')
onCorrected?.()
},
onError: () => toast.error('修改失败'),
})
const handleSaveBase = () => {
const val = Number(editBase) || 0
if (val <= 0) { toast.error('基数必须大于0'); return }
correctMutation.mutate({ base: val })
}
return (
<tr className="border-b last:border-0 hover:bg-gray-50">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className={`px-2 py-0.5 rounded text-xs ${typeClass}`}>{typeLabel}</span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-right">
{editing ? (
<span className="inline-flex items-center gap-1">
<Input type="number" step="0.01" min="0" className="!w-24 text-right text-xs" value={editBase}
onChange={(e) => setEditBase(e.target.value)} autoFocus />
<button className="text-xs text-primary hover:underline" onClick={handleSaveBase} disabled={correctMutation.isPending}>
{correctMutation.isPending ? '...' : '保存'}
</button>
<button className="text-xs text-gray-400 hover:underline" onClick={() => { setEditing(false); setEditBase(i.base?.toString() || '') }}></button>
</span>
) : (
<span className="inline-flex items-center gap-1">
¥{fmt(i.base)}
{i.recordId && type !== 'sub' && (
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => { setEditing(true); setEditBase(i.base?.toString() || '') }}>
</button>
)}
</span>
)}
</td>
<td className="py-1.5 text-right text-danger">{d ? `¥${fmt(d.orgAmount)}` : '-'}</td>
<td className="py-1.5 text-right text-warning">{d ? `¥${fmt(d.empAmount)}` : '-'}</td>
<td className="py-1.5 text-right font-medium text-primary">{d ? `¥${fmt(d.total)}` : '-'}</td>
+2 -4
View File
@@ -167,7 +167,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<Label></Label>
<input ref={contractFileRef} type="file" multiple className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
@@ -209,9 +209,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
}
addContractMutation.mutate(payload)
}} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && form.attachments.length === 0) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
addContractMutation.isPending || !form.startDate
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
</Button>