feat: 统一页面布局与字体样式
This commit is contained in:
+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>
|
||||
|
||||
Reference in New Issue
Block a user