feat: 节假日配置、排班管理独立页面、考勤加班显示优化

- 新增 HolidayConfig 模型,支持法定节假日和调休工作日配置
- 加班费同步逻辑改用 HolidayConfig 判断日期类型
- 员工端考勤显示加班工时、费率和日期类型
- 周末/节假日出勤状态显示为"周末出勤"/"节假日出勤"
- 新增 Employee.defaultShiftId 字段,支持长期排班(工作日班次)
- 排班管理拆分为独立页面(班次管理+排班),考勤管理保留出勤相关功能
- 排班和每日出勤页面增加身份证号列
- 修复岗位和部门编辑失败问题(POST 改 PUT)
- 新增 backfill 脚本:合同薪资回填、默认班次回填

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-18 11:00:53 +08:00
parent eb91c2d8fb
commit 1feada76d1
20 changed files with 1482 additions and 484 deletions
+77 -15
View File
@@ -19,6 +19,14 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'CERTIFICATE' | 'CONTRACT' | 'PHOTO' | 'OTHER'>('ID_CARD')
// 判断最新合同类型,劳务/实习/兼职/外包等非劳动合同不缴纳社保公积金
const latestContract = profile.contracts?.[0]
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(latestContract.contractType)
// 从在保记录中提取社保/公积金账户名
const activeSocialAccount = profile.socialInsRecords?.find((r: any) => !r.endMonth)?.account
const activeHousingAccount = profile.housingFundRecords?.find((r: any) => !r.endMonth)?.account
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => attachmentApi.add(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
@@ -136,6 +144,18 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
enabled: !editing && !!profile.socialInsBase && !!profile.city,
})
// 查询公积金费用明细
const { data: housingDetail } = useQuery<any>({
queryKey: ['housing-calc', profile.id, profile.housingFundBase, profile.city],
queryFn: async () => {
if (!profile.housingFundBase || !profile.city) return null
try {
return await socialInsuranceApi.housingCalculate(Number(profile.housingFundBase), profile.city)
} catch { return null }
},
enabled: !editing && !!profile.housingFundBase && !!profile.city,
})
const handleSave = async () => {
if (form.city !== (profile.city || '') && !form.cityChangeReason.trim()) {
toast.error('参保城市变更必须填写变更原因')
@@ -344,26 +364,27 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
)}
{/* 薪税信息 */}
{/* 社保公积金信息(劳务/实习/兼职/外包等非劳动合同不显示) */}
{!isNoSocialContract && (
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="grid md:grid-cols-4 gap-4">
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.city || '未设置'}</span>
<span className="text-gray-500"></span>
<span className="font-medium">{activeSocialAccount?.name || profile.city || '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<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 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
<span className="text-gray-500"></span>
<span className="font-medium">{activeHousingAccount?.name || profile.city || '未设置'}</span>
</div>
<div className="flex justify-between border-b pb-2 text-xs">
<span className="text-gray-500"></span>
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
<span className="text-gray-500"></span>
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
</div>
{socialDetail?.items?.length > 0 && (
<div className="md:col-span-4 mt-2">
@@ -384,24 +405,45 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
</div>
)}
{housingDetail && (
<div className="md:col-span-4 mt-2">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="grid md:grid-cols-3 gap-2">
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{housingDetail.orgRate}%</div>
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.housingOrg)}</div>
</div>
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700">{housingDetail.empRate}%</div>
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.housingEmp)}</div>
</div>
<div className="px-2 py-1.5 rounded bg-gray-50 text-xs">
<div className="font-medium text-gray-700"></div>
<div className="text-gray-500 mt-0.5">¥{fmt(housingDetail.total)}</div>
</div>
</div>
{housingDetail.capped && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(housingDetail.actualBase)}</div>}
{housingDetail.floored && <div className="text-xs text-amber-600 mt-1"> ¥{fmt(housingDetail.actualBase)}</div>}
</div>
)}
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
<div>
<Label></Label>
<Input placeholder="如 北京" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
<Label></Label>
<Input placeholder="如 北京社保" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
<div>
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
<Label></Label>
<Input placeholder="如 北京公积金" value={form.city || ''} onChange={(e) => setForm({ ...form, city: e.target.value })} disabled />
</div>
<div>
<Label>/</Label>
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
<Label></Label>
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} />
</div>
{form.city !== (profile.city || '') && (
<div className="md:col-span-4">
@@ -411,7 +453,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
)}
</div>
)}
<p className="text-xs text-gray-400 mt-2">/7portal端填报0</p>
<p className="text-xs text-gray-400 mt-2">/7</p>
{!editing && (!profile.socialInsBase || !profile.housingFundBase) && (
<div className="mt-2 flex items-center gap-2 px-3 py-2 rounded-md bg-amber-50 text-warning text-xs">
<AlertTriangle className="w-4 h-4 shrink-0" />
@@ -419,6 +461,26 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
</div>
)}
</div>
)}
{/* 专项附加扣除(独立区域,所有合同类型都显示) */}
<div className="mt-4 pt-4 border-t">
<h3 className="text-xs font-medium text-gray-600 mb-3"></h3>
{!editing ? (
<div className="flex items-center gap-4">
<span className="text-xs text-gray-500"></span>
<span className="text-sm font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
</div>
) : (
<div className="grid md:grid-cols-4 gap-4">
<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">portal端填报0</p>
</div>
{/* 特殊状态 */}
<div className="mt-4 pt-4 border-t">