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
+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>