feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化
- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页 - Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度) - 待办分为「风险提醒」「月度任务」两个顶层 tab - 归档与工资条生成解耦:归档只锁定批次,工资条单独生成 - 工资条管理新增「从批次汇总生成」按钮 - Dashboard 总览优先从已归档批次 BatchEntry 汇总数据 - 新增工资条生成待办提醒,生成后自动标记完成 - 修复高风险统计只含 CONTRACT/TERMINATION 类型 - 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
@@ -7,6 +7,10 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import Signal from '../components/ui/Signal'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
|
||||
|
||||
@@ -15,6 +19,8 @@ export default function Roster() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const { data: employees, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['roster'],
|
||||
@@ -36,6 +42,7 @@ export default function Roster() {
|
||||
const filtered = employees?.filter((e: any) =>
|
||||
!search || e.name.includes(search) || e.department.includes(search)
|
||||
) || []
|
||||
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
if (selectedId) {
|
||||
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
|
||||
@@ -64,6 +71,7 @@ export default function Roster() {
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无员工</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -82,7 +90,7 @@ export default function Roster() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e: any) => (
|
||||
{paged.map((e: any) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
|
||||
@@ -96,7 +104,7 @@ export default function Roster() {
|
||||
</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-right">¥{e.monthlySalary.toLocaleString()}</td>
|
||||
<td className="py-2 px-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<Signal level={e.latestContract?.riskLevel || 'safe'} label={e.latestContract ? (e.latestContract.contractType === 'FIXED' ? '固定期限' : e.latestContract.contractType === 'UNFIXED' ? '无固定期限' : '未签') : '无合同'} />
|
||||
</td>
|
||||
@@ -204,13 +212,30 @@ function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: (
|
||||
}
|
||||
|
||||
function BasicInfo({ profile }: { profile: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
socialInsBase: profile.socialInsBase ?? '',
|
||||
housingFundBase: profile.housingFundBase ?? '',
|
||||
specialDeduction: profile.specialDeduction ?? 0,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setEditing(false)
|
||||
},
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{ label: '姓名', value: profile.name },
|
||||
{ label: '部门', value: profile.department },
|
||||
{ label: '性别', value: profile.gender || '未填写' },
|
||||
{ label: '手机号', value: profile.phone || '未填写' },
|
||||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||||
{ label: '月工资', value: `¥${profile.monthlySalary.toLocaleString()}` },
|
||||
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
|
||||
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
|
||||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||||
{ label: '住址', value: profile.address || '未填写' },
|
||||
@@ -225,6 +250,23 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
]
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-medium">基本信息</h2>
|
||||
{!editing ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>编辑薪税信息</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => updateMutation.mutate({
|
||||
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
|
||||
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
|
||||
specialDeduction: Number(form.specialDeduction) || 0,
|
||||
})} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>取消</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{fields.map((f) => (
|
||||
<div key={f.label} className="flex justify-between border-b pb-2">
|
||||
@@ -233,6 +275,44 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 薪税信息 */}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-3">薪税信息</h3>
|
||||
{!editing ? (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">社保缴费基数</span>
|
||||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">公积金缴费基数</span>
|
||||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-500">专项附加扣除</span>
|
||||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>专项附加扣除(元/月)</Label>
|
||||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-4">
|
||||
{special.map((s) => (
|
||||
<span key={s.label} className={`px-3 py-1 rounded text-sm ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
|
||||
@@ -487,11 +567,11 @@ function PayslipInfo({ payslips }: { payslips: any[] }) {
|
||||
{payslips.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.month}</td>
|
||||
<td className="py-2 text-right">¥{p.baseSalary.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.overtimePay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{p.allowance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{p.deduction.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-bold">¥{p.totalPay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.baseSalary)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.overtimePay)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(p.allowance)}</td>
|
||||
<td className="py-2 text-right">{p.deduction > 0 ? '-¥' : '¥'}{fmt(p.deduction)}</td>
|
||||
<td className="py-2 text-right font-bold">¥{fmt(p.totalPay)}</td>
|
||||
<td className="py-2 text-center">
|
||||
{p.confirmedAt ? <span className="text-safe text-xs">已确认</span> : <span className="text-warning text-xs">未确认</span>}
|
||||
</td>
|
||||
@@ -524,7 +604,7 @@ function OvertimeInfo({ records }: { records: any[] }) {
|
||||
<td className="py-2 text-right">{o.weekdayHours}</td>
|
||||
<td className="py-2 text-right">{o.weekendHours}</td>
|
||||
<td className="py-2 text-right">{o.holidayHours}</td>
|
||||
<td className="py-2 text-right font-medium">¥{o.totalPay.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 text-right font-medium">¥{fmt(o.totalPay)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -581,7 +661,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
因 <strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> 原因,公司决定于 <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong> 起解除与您的劳动合同。
|
||||
</p>
|
||||
<p>解除依据:{legalBasisMap[printRecord.reason] || ''}</p>
|
||||
<p>经济补偿金:<strong>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</strong></p>
|
||||
<p>经济补偿金:<strong>¥{fmt(printRecord.compensation)}</strong></p>
|
||||
{printRecord.remark && <p>备注:{printRecord.remark}</p>}
|
||||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||||
<div className="text-right mt-6 space-y-1">
|
||||
@@ -597,10 +677,10 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between"><span>员工</span><span>{profile.name}({profile.department})</span></div>
|
||||
<div className="flex justify-between"><span>入职日期</span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{profile.monthlySalary.toLocaleString()}/月</span></div>
|
||||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(profile.monthlySalary)}/月</span></div>
|
||||
<div className="flex justify-between"><span>解聘日期</span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span>解聘原因</span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{printRecord.compensation.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{fmt(printRecord.compensation)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -678,7 +758,7 @@ function TerminationInfo({ employeeId, profile, records }: { employeeId: string;
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex justify-between"><span className="text-gray-500">解聘日期</span><span className="font-medium">{t.terminationDate?.toString().slice(0, 10)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">解聘原因</span><span className="font-medium">{reasonMap[t.reason] || t.reason}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">经济补偿金</span><span className="font-medium">¥{t.compensation.toLocaleString()}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">经济补偿金</span><span className="font-medium">¥{fmt(t.compensation)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">风险等级</span><span className="font-medium">{t.riskLevel === 'SAFE' ? '安全' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}</span></div>
|
||||
{t.remark && <div className="md:col-span-2"><span className="text-gray-500">备注:</span><span>{t.remark}</span></div>}
|
||||
<div className="md:col-span-2 flex justify-end">
|
||||
|
||||
Reference in New Issue
Block a user