feat: 分页组件、Dashboard待办图标、归档与工资条解耦、总览数据优化

- 新增公用 Pagination 组件,Roster/Money/Dashboard 列表加分页
- Dashboard 待办按类型显示不同图标(合同/薪资/解聘/月度)
- 待办分为「风险提醒」「月度任务」两个顶层 tab
- 归档与工资条生成解耦:归档只锁定批次,工资条单独生成
- 工资条管理新增「从批次汇总生成」按钮
- Dashboard 总览优先从已归档批次 BatchEntry 汇总数据
- 新增工资条生成待办提醒,生成后自动标记完成
- 修复高风险统计只含 CONTRACT/TERMINATION 类型
- 修复月度任务去重逻辑覆盖 SALARY 类型
This commit is contained in:
freedakgmail
2026-07-23 13:42:28 +08:00
parent 820579e98d
commit 2a09d31ccc
20 changed files with 2300 additions and 235 deletions
+93 -13
View File
@@ -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">/7portal端填报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">