feat: Phase 1-3 优化全部完成
Phase 1 紧急修复(8项): - 社保城市选择改为可输入 - 社保上下限拆分(三险/医保独立基数) - 公积金试算结果展示修复 - 花名册合同保存修复(日期ISO格式) - 薪酬批次创建失败修复(城市过滤+错误处理) - 证据链查看修复 - 个税计算修复(blank_employees读取基本工资) - 加班费倍率读取配置 Phase 2 功能完善(3项): - 批量导入per-row异常捕获+导入按钮 - 单人发薪UI入口优化 - 解除协议模板补充(员工提出离职版) Phase 3 后期规划(4项): - 工资表导入功能(POST /import/payroll + 前端入口) - 大病险/长护险附加险种(extraInsurances JSON + 计算适配) - 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab) - 预置河北省社保政策(seed数据)
This commit is contained in:
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -374,6 +375,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||||
const payrollFileRef = useRef<HTMLInputElement>(null)
|
||||
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
|
||||
|
||||
const { data: batch, isLoading } = useQuery<any>({
|
||||
queryKey: ['batch-detail', batchId],
|
||||
@@ -423,6 +426,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
},
|
||||
})
|
||||
|
||||
const importPayrollMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('batchId', batchId)
|
||||
const res = await fetch('/api/v1/import/payroll', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(data.message || '导入失败')
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
setPayrollImportResult(data)
|
||||
if (data.updated > 0) {
|
||||
toast.success(`成功更新 ${data.updated} 条工资记录`)
|
||||
} else {
|
||||
toast.info('未更新任何记录')
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('工资表导入失败'),
|
||||
})
|
||||
|
||||
const deleteBatchMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/payroll2/batches/${batchId}`),
|
||||
onSuccess: () => {
|
||||
@@ -546,6 +576,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加人员
|
||||
</Button>
|
||||
<input
|
||||
ref={payrollFileRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) importPayrollMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => payrollFileRef.current?.click()}
|
||||
disabled={importPayrollMutation.isPending}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{importPayrollMutation.isPending ? '导入中...' : '导入工资表'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />下载模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -636,6 +693,29 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
|
||||
)}
|
||||
|
||||
{/* 工资表导入结果 */}
|
||||
{payrollImportResult && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium">工资表导入结果</h3>
|
||||
<button onClick={() => setPayrollImportResult(null)} className="text-gray-500"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="text-gray-600">总计 {payrollImportResult.total} 行,成功更新 {payrollImportResult.updated} 条</div>
|
||||
{payrollImportResult.errors?.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-warning text-xs font-medium">错误详情({payrollImportResult.errors.length}条):</div>
|
||||
<ul className="mt-1 space-y-0.5 text-xs text-danger max-h-40 overflow-y-auto">
|
||||
{payrollImportResult.errors.map((err: string, i: number) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
{!isArchived && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History } from 'lucide-react'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -263,6 +263,9 @@ export default function Roster() {
|
||||
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.hash = '#/settings'} className="h-9 shrink-0">
|
||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,6 +439,18 @@ export default function Roster() {
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
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()
|
||||
window.location.hash = '#/money'
|
||||
}}
|
||||
>
|
||||
<Wallet className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="调部门"
|
||||
|
||||
@@ -14,9 +14,10 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing'>('monthly')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
@@ -34,6 +35,8 @@ export default function SocialInsurance() {
|
||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
medicalBaseMin: 0, medicalBaseMax: 0,
|
||||
extraInsurances: [],
|
||||
})
|
||||
const [newHousingVersion, setNewHousingVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
@@ -320,7 +323,7 @@ export default function SocialInsurance() {
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
@@ -328,23 +331,22 @@ export default function SocialInsurance() {
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : '公积金'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{tab !== 'monthly' && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<select
|
||||
<input
|
||||
list="social-cities"
|
||||
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"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
placeholder="输入或选择城市"
|
||||
/>
|
||||
<datalist id="social-cities">
|
||||
{cities.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,6 +404,12 @@ export default function SocialInsurance() {
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
{activeConfig.medicalBaseMin > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数下限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMin)}</span></div>
|
||||
)}
|
||||
{activeConfig.medicalBaseMax > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数上限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMax)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医疗(企业/个人)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
|
||||
@@ -571,6 +579,20 @@ export default function SocialInsurance() {
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>医保/生育基数下限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数下限</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>医保/生育基数上限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数上限</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
@@ -588,6 +610,48 @@ export default function SocialInsurance() {
|
||||
<div><Label>生育(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">附加险种(大病险/长护险等)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
extraInsurances: [...(activeNewVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
|
||||
})}
|
||||
>
|
||||
+ 添加险种
|
||||
</button>
|
||||
</div>
|
||||
{(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => (
|
||||
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
|
||||
<div><Label>险种名称</Label><Input value={ins.name} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div>
|
||||
<Label>计算方式</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>
|
||||
<option value="pension">按养老基数</option>
|
||||
<option value="medical">按医保基数</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
{ins.baseType === 'fixed' ? (
|
||||
<>
|
||||
<div><Label>企业固定(元)</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人固定(元)</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><Label>企业%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (activeNewVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
|
||||
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
|
||||
@@ -625,6 +689,37 @@ export default function SocialInsurance() {
|
||||
{(() => {
|
||||
const r = isHousing ? housingResult : result
|
||||
if (!r) return <div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
if (isHousing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">企业缴纳</span>
|
||||
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">个人缴纳</span>
|
||||
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
@@ -645,7 +740,7 @@ export default function SocialInsurance() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items.map((item: any) => (
|
||||
{r.items?.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
@@ -904,6 +999,11 @@ export default function SocialInsurance() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
{tab === 'deduction' && (
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
@@ -980,3 +1080,173 @@ function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' |
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** 专项附加扣除按月录入组件 */
|
||||
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<any>(null)
|
||||
|
||||
// 查询当月所有员工的专项附加扣除
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['special-deduction', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/special-deduction/batch', { params: { month } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
// 查询所有员工列表(用于添加未录入的员工)
|
||||
const { data: employees = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-deduction'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster', { params: { pageSize: 999 } }) as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/social/special-deduction', { ...data, month }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
||||
setEditing(null)
|
||||
setEditForm(null)
|
||||
},
|
||||
})
|
||||
|
||||
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
|
||||
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
|
||||
|
||||
const startEdit = (empId: string, existing?: any) => {
|
||||
setEditing(empId)
|
||||
setEditForm(existing ? {
|
||||
children: existing.children,
|
||||
elderly: existing.elderly,
|
||||
housing: existing.housing,
|
||||
education: existing.education,
|
||||
infant: existing.infant,
|
||||
remark: existing.remark,
|
||||
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
|
||||
}
|
||||
|
||||
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">专项附加扣除 — {month}</h2>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 已录入列表 */}
|
||||
{records.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 font-medium">姓名</th>
|
||||
<th className="py-2 font-medium">部门</th>
|
||||
<th className="py-2 font-medium text-right">子女教育</th>
|
||||
<th className="py-2 font-medium text-right">赡养老人</th>
|
||||
<th className="py-2 font-medium text-right">住房</th>
|
||||
<th className="py-2 font-medium text-right">继续教育</th>
|
||||
<th className="py-2 font-medium text-right">婴幼儿照护</th>
|
||||
<th className="py-2 font-medium text-right">合计</th>
|
||||
<th className="py-2 font-medium">备注</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
{editing === r.employeeId ? (
|
||||
<>
|
||||
<td className="py-1.5">{r.employee?.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
|
||||
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
|
||||
<td className="py-1">
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
||||
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-1.5">{r.employee?.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
||||
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
|
||||
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
|
||||
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}>编辑</button></td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未录入员工 */}
|
||||
{unrecorded.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-2">未录入员工({unrecorded.length}人)</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unrecorded.map((e: any) => (
|
||||
<button
|
||||
key={e.id}
|
||||
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
|
||||
onClick={() => startEdit(e.id)}
|
||||
>
|
||||
{e.name}({e.department})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑表单 */}
|
||||
{editing && !recordMap.has(editing) && (
|
||||
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
|
||||
<h3 className="text-xs font-medium">新增专项附加扣除 — {employees.find((e: any) => e.id === editing)?.name}</h3>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div><Label>子女教育</Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
|
||||
<div><Label>赡养老人</Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
|
||||
<div><Label>住房</Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
|
||||
<div><Label>继续教育</Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
|
||||
<div><Label>婴幼儿照护</Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1"><Label>备注</Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
|
||||
<div className="text-sm text-gray-500 pt-5">合计:<span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{records.length === 0 && unrecorded.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无员工数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
</>
|
||||
)}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button onClick={() => addContractMutation.mutate(form)} disabled={
|
||||
<Button onClick={() => {
|
||||
const payload = {
|
||||
...form,
|
||||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||||
}
|
||||
addContractMutation.mutate(payload)
|
||||
}} disabled={
|
||||
addContractMutation.isPending || !form.startDate ||
|
||||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
|
||||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
|
||||
|
||||
@@ -12,6 +12,7 @@ import AttendanceOvertimeInfo from './AttendanceOvertimeInfo'
|
||||
import PerformanceInfo from './PerformanceInfo'
|
||||
import TerminationInfo from './TerminationInfo'
|
||||
import ChangeHistoryTab from './ChangeHistoryTab'
|
||||
import EvidenceChain from './EvidenceChain'
|
||||
|
||||
/**
|
||||
* 员工详情档案页
|
||||
@@ -99,6 +100,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
||||
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,22 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">生成证据链中...</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">无数据</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">暂无证据链数据,请确保员工已录入合同、薪酬等信息</div>
|
||||
if (!data.evidence || data.evidence.length === 0) return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" />仲裁证据链</h2>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.employee?.name || '未知'} · {data.employee?.department || '未知'} · 入职{data.employee?.hireDate ? data.employee.hireDate.toString().slice(0, 10) : '未知'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card><div className="text-center py-8 text-gray-400">该员工暂无证据链记录,录入合同、薪酬、考勤等信息后将自动生成</div></Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const terminateReasonMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** 详情页 Tab 类型 */
|
||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history'
|
||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history'
|
||||
|
||||
/** Tab 分组 */
|
||||
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
|
||||
@@ -45,6 +45,7 @@ export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: strin
|
||||
tabs: [
|
||||
{ key: 'disciplinary', label: '违纪记录', icon: null },
|
||||
{ key: 'termination', label: '离职/解聘', icon: null },
|
||||
{ key: 'evidence', label: '证据链', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user