feat: 20260805 系统优化 - 身份证复制fallback/薪税日期筛选/社保版本修复/证据链导出/违纪证明/医疗期政策/绩效类型评级/合同作废/帮助更新

This commit is contained in:
freedakgmail
2026-08-05 20:26:16 +08:00
parent a5901d648e
commit c355a7d208
24 changed files with 1185 additions and 257 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
import { copyToClipboard } from '../lib/clipboard'
import { useAuthStore } from '../store/authStore'
import { useDebouncedValue } from '../hooks/useDebouncedValue'
import Card from '../components/ui/Card'
@@ -522,7 +523,7 @@ export default function Roster() {
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
ev.stopPropagation()
if (e.idCardNumber) {
navigator.clipboard.writeText(e.idCardNumber).then(() => toast.success('已复制身份证号')).catch(() => toast.error('复制失败'))
copyToClipboard(e.idCardNumber, '已复制身份证号')
}
}}>{e.idCardMasked || '—'}</div>
</td>
+187 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool } from 'lucide-react'
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
import { settingsApi, notificationsApi } from '../lib/api-services'
import { useAuthStore } from '../store/authStore'
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
@@ -14,7 +14,7 @@ import { useConfirm } from '../hooks/useConfirm'
export default function Settings() {
const queryClient = useQueryClient()
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'import' | 'export'>('org')
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'retirement' | 'medical' | 'import' | 'export'>('org')
const { data: orgData } = useQuery<any>({
queryKey: ['org-settings'],
@@ -41,6 +41,7 @@ export default function Settings() {
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
{ key: 'retirement' as const, label: '退休提醒', icon: Clock },
{ key: 'medical' as const, label: '医疗期政策', icon: HeartPulse },
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
{ key: 'export' as const, label: '数据导出', icon: Download },
]
@@ -82,6 +83,7 @@ export default function Settings() {
{activeSection === 'retirement' && (
<RetirementSettings orgData={orgData} onSave={(data) => updateOrgMutation.mutate(data)} />
)}
{activeSection === 'medical' && <MedicalPeriodSettings />}
{activeSection === 'import' && <ImportSettings />}
{activeSection === 'export' && <ExportSettings />}
</div>
@@ -1387,3 +1389,186 @@ function MonthlyImport() {
)
}
function MedicalPeriodSettings() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editPolicy, setEditPolicy] = useState<any>(null)
const { data: policies = [], isLoading } = useQuery<any[]>({
queryKey: ['medical-period-policies'],
queryFn: () => settingsApi.medicalPeriodPolicies(),
})
const saveMut = useMutation({
mutationFn: (data: any) => settingsApi.saveMedicalPeriodPolicy(data),
onSuccess: () => {
toast.success('政策已保存')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
setShowForm(false)
setEditPolicy(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => settingsApi.deleteMedicalPeriodPolicy(id),
onSuccess: () => {
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['medical-period-policies'] })
},
onError: () => toast.error('删除失败'),
})
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-sm font-medium"></h2>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<Button size="sm" onClick={() => { setEditPolicy(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="py-8 text-center text-gray-400 text-sm">...</div>
) : policies.length === 0 ? (
<div className="py-8 text-center text-gray-400 text-sm"></div>
) : (
<div className="space-y-2">
{policies.map((p: any) => (
<div key={p.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{p.region}</span>
{p.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditPolicy(p); setShowForm(true) }} className="text-xs text-primary hover:underline"></button>
{!p.isDefault && (
<button onClick={() => { if (confirm(`确认删除「${p.region}」政策?`)) deleteMut.mutate(p.id) }} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
<div className="text-xs text-gray-500 mb-2">{p.legalBasis}</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-400">
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
<th className="py-1 pr-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y">
{p.rules.map((rule: any, idx: number) => {
const prevMax = idx > 0 ? p.rules[idx - 1].maxYears : 0
const isLast = idx === p.rules.length - 1
return (
<tr key={idx}>
<td className="py-1 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears}`}</td>
<td className="py-1 pr-3">{rule.months} </td>
<td className="py-1 pr-3">{rule.cycleMonths} </td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
</Card>
{showForm && (
<MedicalPolicyForm
policy={editPolicy}
onSave={(data) => saveMut.mutate(data)}
onClose={() => { setShowForm(false); setEditPolicy(null) }}
/>
)}
</div>
)
}
function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (data: any) => void; onClose: () => void }) {
const [region, setRegion] = useState(policy?.region || '')
const [legalBasis, setLegalBasis] = useState(policy?.legalBasis || '')
const [isDefault, setIsDefault] = useState(policy?.isDefault || false)
const [rules, setRules] = useState<any[]>(
policy?.rules?.length ? policy.rules : [{ maxYears: 5, months: 3, cycleMonths: 6 }]
)
const addRule = () => setRules([...rules, { maxYears: 10, months: 6, cycleMonths: 12 }])
const updateRule = (idx: number, field: string, value: number) => {
setRules(rules.map((r, i) => i === idx ? { ...r, [field]: value } : r))
}
const removeRule = (idx: number) => {
if (rules.length <= 1) return
setRules(rules.filter((_, i) => i !== idx))
}
const handleSave = () => {
if (!region.trim()) { toast.error('请输入地区名称'); return }
if (!legalBasis.trim()) { toast.error('请输入法律依据'); return }
const sortedRules = [...rules].sort((a, b) => a.maxYears - b.maxYears)
onSave({ region: region.trim(), legalBasis: legalBasis.trim(), rules: sortedRules, isDefault })
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{policy ? '编辑政策' : '新增政策'}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Input value={region} onChange={(e) => setRegion(e.target.value)} placeholder="如:广东" disabled={!!policy?.isDefault} />
</div>
<div>
<Label></Label>
<Input value={legalBasis} onChange={(e) => setLegalBasis(e.target.value)} placeholder="如:《广东省...》" />
</div>
<div>
<Label></Label>
<div className="space-y-2">
{rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap"> &lt;</span>
<input type="number" value={rule.maxYears} onChange={(e) => updateRule(idx, 'maxYears', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"> </span>
<input type="number" value={rule.months} onChange={(e) => updateRule(idx, 'months', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
<input type="number" value={rule.cycleMonths} onChange={(e) => updateRule(idx, 'cycleMonths', parseInt(e.target.value) || 0)} className="w-16 px-2 py-1 text-xs border rounded" />
<span className="text-xs text-gray-500"></span>
{rules.length > 1 && (
<button onClick={() => removeRule(idx)} className="p-1 hover:bg-gray-100 rounded">
<Trash2 className="w-3 h-3 text-red-400" />
</button>
)}
</div>
))}
<button onClick={addRule} className="text-xs text-primary hover:underline">+ </button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} disabled={!!policy?.isDefault} />
<span></span>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSave}></Button>
</div>
</div>
</div>
</div>
)
}
+8
View File
@@ -188,6 +188,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const createHousingVersionMutation = useMutation({
@@ -198,6 +202,10 @@ export default function SocialInsurance() {
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || '创建失败'
toast.error(msg)
},
})
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
+2 -2
View File
@@ -676,11 +676,11 @@ function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDe
onPreview(data.id)
return
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${doc.name}.txt`
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
a.click()
URL.revokeObjectURL(url)
}}
+17 -5
View File
@@ -101,6 +101,8 @@ export function BatchManager() {
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [monthFrom, setMonthFrom] = useState('')
const [monthTo, setMonthTo] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [filterType, setFilterType] = useState<string>('')
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
@@ -120,12 +122,14 @@ export function BatchManager() {
})
const { data: batches, isLoading } = useQuery<any[]>({
queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType],
queryKey: ['batches', month, monthFrom, monthTo, dateFrom, dateTo, filterStatus, filterType],
queryFn: async () => {
const params: any = {}
if (month && !monthFrom && !monthTo) params.month = month
if (month && !monthFrom && !monthTo && !dateFrom && !dateTo) params.month = month
if (monthFrom) params.monthFrom = monthFrom
if (monthTo) params.monthTo = monthTo
if (dateFrom) params.dateFrom = dateFrom
if (dateTo) params.dateTo = dateTo
if (filterStatus) params.status = filterStatus
if (filterType) params.type = filterType
return await payrollApi.batches(params)
@@ -215,9 +219,15 @@ export function BatchManager() {
<span className="text-xs text-gray-500"></span>
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="!w-36" />
</div>
{!monthFrom && !monthTo && (
{!monthFrom && !monthTo && !dateFrom && !dateTo && (
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-36 shrink-0" placeholder="单月" />
)}
<div className="flex items-center gap-1 shrink-0">
<span className="text-xs text-gray-500"></span>
<Input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setMonth('') }} className="!w-36" placeholder="起始" />
<span className="text-xs text-gray-500">~</span>
<Input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setMonth('') }} className="!w-36" placeholder="截止" />
</div>
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="!w-28 shrink-0">
<option value=""></option>
<option value="DRAFT">稿</option>
@@ -230,8 +240,8 @@ export function BatchManager() {
<option value="BONUS"></option>
<option value="SEVERANCE"></option>
</Select>
{(monthFrom || monthTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
{(monthFrom || monthTo || dateFrom || dateTo || filterStatus || filterType) && (
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setDateFrom(''); setDateTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-500 hover:text-primary shrink-0">
</button>
)}
@@ -327,6 +337,7 @@ export function BatchManager() {
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3 text-right"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3"></th>
<th className="py-2 px-3 text-right"></th>
</tr>
@@ -376,6 +387,7 @@ export function BatchManager() {
<td className="py-2.5 px-3 text-right text-sm text-cyan-600">¥{fmt((batch.totalHousingOrg || 0) + (batch.totalHousingEmp || 0))}</td>
<td className="py-2.5 px-3 text-right text-sm text-danger">¥{fmt(batch.totalTax)}</td>
<td className="py-2.5 px-3 text-right text-sm font-bold text-safe">¥{fmt(batch.totalNetPay)}</td>
<td className="py-2.5 px-3 text-xs text-gray-500">{batch.createdAt ? new Date(batch.createdAt).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '-'}</td>
<td className="py-2.5 px-3">
{batch.status === 'ARCHIVED' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-50 text-safe">
+2 -3
View File
@@ -3,6 +3,7 @@ import { useState, useRef } from "react"
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { attachmentApi, employeeApi } from '../../lib/api-services'
import { copyToClipboard } from '../../lib/clipboard'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
@@ -203,9 +204,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
className="text-gray-400 hover:text-primary transition-colors shrink-0"
title="复制身份证号"
onClick={() => {
navigator.clipboard.writeText(profile.idCardNumber)
.then(() => toast.success('已复制身份证号'))
.catch(() => toast.error('复制失败'))
copyToClipboard(profile.idCardNumber, '已复制身份证号')
}}
>
<Copy className="w-3 h-3" />
+105 -11
View File
@@ -1,4 +1,5 @@
import { useState, useRef } from "react"
import api from '../../lib/api'
import { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { employeeApi, esignApi } from '../../lib/api-services'
@@ -14,7 +15,59 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const supplementFileRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('附件')
const uploadAttachmentMutation = useMutation({
mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => {
await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
toast.success('附件已上传')
},
onError: () => toast.error('上传失败'),
})
const handleSupplementUpload = (e: React.ChangeEvent<HTMLInputElement>, contractId: string, existingAtts: { name: string; url: string }[]) => {
const files = e.target.files
if (!files || files.length === 0) return
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif']
const maxSize = 10 * 1024 * 1024
const validFiles: File[] = []
for (const file of Array.from(files)) {
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedExts.includes(ext)) {
toast.error(`不支持的文件格式: ${file.name}`)
continue
}
if (file.size > maxSize) {
toast.error(`文件过大: ${file.name}(最大 10MB`)
continue
}
validFiles.push(file)
}
if (validFiles.length === 0) return
const promises = validFiles.map(file => new Promise<{ name: string; url: string }>((resolve) => {
const reader = new FileReader()
reader.onload = (event) => {
resolve({ name: file.name, url: event.target?.result as string })
}
reader.onerror = () => {
toast.error(`读取文件失败: ${file.name}`)
resolve({ name: file.name, url: '' })
}
reader.readAsDataURL(file)
}))
Promise.all(promises).then(atts => {
const validAtts = atts.filter(a => a.url)
if (validAtts.length === 0) return
const merged = [...existingAtts, ...validAtts]
uploadAttachmentMutation.mutate({ contractId, attachmentUrl: JSON.stringify(merged) })
})
e.target.value = ''
}
const addContractMutation = useMutation({
mutationFn: async (data: any) => {
@@ -43,7 +96,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => employeeApi.removeContract(contractId),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') },
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已作废') },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -261,6 +314,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
{(() => {
let atts: { name: string; url: string }[] = []
try {
if (!c.attachmentUrl) throw new Error('empty')
const parsed = JSON.parse(c.attachmentUrl)
atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }]
} catch {
@@ -270,19 +324,45 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.length === 0 && <span className="text-gray-400 ml-2"></span>}
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<button onClick={() => { setPreviewName(att.name); setPreviewUrl(att.url) }} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<button
type="button"
className="text-gray-400 hover:text-primary ml-2 shrink-0"
title="下载附件"
onClick={() => {
const dataToBlobUrl = (dataUrl: string) => {
try {
const arr = dataUrl.split(',')
const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream'
const bstr = atob(arr[1])
const u8 = new Uint8Array(bstr.length)
for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i)
return URL.createObjectURL(new Blob([u8], { type: mime }))
} catch { return dataUrl }
}
const blobUrl = att.url.startsWith('data:') ? dataToBlobUrl(att.url) : att.url
const a = document.createElement('a')
a.href = blobUrl
a.download = att.name
a.click()
if (blobUrl !== att.url) URL.revokeObjectURL(blobUrl)
}}
>
<Download className="w-3 h-3" />
</a>
</button>
</div>
))}
<input id={`contract-file-${c.id}`} type="file" multiple className="hidden" onChange={(e) => handleSupplementUpload(e, c.id, atts)} />
<button type="button" onClick={() => document.getElementById(`contract-file-${c.id}`)?.click()} disabled={uploadAttachmentMutation.isPending} className="inline-flex items-center justify-center font-medium rounded-md transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-1.5 text-xs">
<Paperclip className="w-3 h-3 mr-1" />{atts.length > 0 ? '补充上传' : '上传附件'}
</button>
</div>
)
})()}
@@ -298,9 +378,9 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
)}
</div>
<button
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
onClick={async () => { if (await confirm({ title: '作废合同', message: '确定作废此合同记录?作废后记录将保留但不再生效。' })) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
title="作废合同"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -332,9 +412,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<button type="button" onClick={() => {
const a = document.createElement('a')
a.href = blobUrl
a.download = previewName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}} className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
</button>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
@@ -349,9 +436,16 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<button type="button" onClick={() => {
const a = document.createElement('a')
a.href = blobUrl
a.download = previewName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</button>
</div>
)}
</div>
+31 -2
View File
@@ -4,7 +4,9 @@ import { rosterApi } from '../../lib/api-services'
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import { AlertTriangle, Check } from "lucide-react"
import { AlertTriangle, Check, Download } from "lucide-react"
import { toast } from "sonner"
import { useAuthStore } from '../../store/authStore'
// ========== 违纪记录管理 ==========
@@ -104,7 +106,34 @@ export default function DisciplinaryInfo({ employeeId, records }: { employeeId:
{r.ackMethod && <span className="text-gray-400">{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
</div>
</div>
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
<div className="flex items-center gap-2">
{r.employeeAck && (
<button
onClick={async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch(`/api/v1/roster/${employeeId}/disciplinary/${r.id}/certificate`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `违纪确认证明_${r.violationDate?.toString().slice(0, 10)}.doc`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('下载失败')
}
}}
className="text-xs text-primary hover:underline flex items-center gap-1 shrink-0"
>
<Download className="w-3 h-3" />
</button>
)}
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0"></button>
</div>
</div>
</Card>
))}
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, X } from 'lucide-react'
import { Search, Plus, Edit2, Trash2, X, Download } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
@@ -139,6 +139,35 @@ export default function DisciplinaryRecords() {
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
{r.employeeAck && (
<button
title="下载违纪确认证明"
onClick={async () => {
try {
const { useAuthStore } = await import('../../store/authStore')
const token = useAuthStore.getState().accessToken
const res = await fetch(`/api/v1/roster/${r.employeeId}/disciplinary/${r.id}/certificate`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const cd = res.headers.get('content-disposition') || ''
const fname = cd.match(/filename\*=UTF-8''(.+)/)?.[1] || cd.match(/filename="(.+?)"/)?.[1] || '违纪确认证明.doc'
a.download = decodeURIComponent(fname)
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch { toast.error('下载失败') }
}}
className="p-1 hover:bg-gray-100 rounded"
>
<Download className="w-3.5 h-3.5 text-primary" />
</button>
)}
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
+25 -5
View File
@@ -11,7 +11,7 @@ import { AlertTriangle, Check } from "lucide-react"
export default function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const [form, setForm] = useState({ period: '', periodType: 'MONTHLY' as 'MONTHLY' | 'QUARTERLY' | 'YEARLY', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
const createMutation = useMutation({
mutationFn: (data: any) => rosterApi.performance(employeeId, data),
@@ -25,6 +25,19 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
// 根据得分自动计算等级和结果
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' }
return { grade: 'D', result: 'UNQUALIFIED' }
}
const handleScoreChange = (score: number) => {
const { grade, result } = scoreToGrade(score)
setForm({ ...form, score, grade, result })
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
@@ -35,14 +48,21 @@ export default function PerformanceInfo({ employeeId, records }: { employeeId: s
{showForm && (
<Card>
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
<div><Label></Label>
<div><Label></Label>
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value as any })}>
<option value="MONTHLY"></option>
<option value="QUARTERLY"></option>
<option value="YEARLY"></option>
</Select>
</div>
<div><Label></Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'MONTHLY' ? '如 2026-07' : form.periodType === 'QUARTERLY' ? '如 2026-Q3' : '如 2026'} /></div>
<div><Label></Label><Input type="number" value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} /></div>
<div><Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
</Select>
</div>
<div><Label></Label>
<div><Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</Select>
@@ -181,6 +181,7 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
period: record?.period || new Date().toISOString().slice(0, 7),
periodType: record?.periodType || 'MONTHLY',
score: record?.score || 80,
grade: record?.grade || 'B',
result: record?.result || 'QUALIFIED',
@@ -189,6 +190,18 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
reviewer: record?.reviewer || '',
})
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' }
return { grade: 'D', result: 'UNQUALIFIED' }
}
const handleScoreChange = (score: number) => {
const { grade, result } = scoreToGrade(score)
setForm({ ...form, score, grade, result })
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
@@ -197,6 +210,16 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
<div className="space-y-3">
{!record && (
<div>
<Label></Label>
<Select value={form.periodType} onChange={(e) => setForm({ ...form, periodType: e.target.value })}>
<option value="MONTHLY"></option>
<option value="QUARTERLY"></option>
<option value="YEARLY"></option>
</Select>
</div>
)}
{!record && (
<div>
<Label></Label>
@@ -210,15 +233,15 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
)}
<div>
<Label></Label>
<Input type="month" value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} />
<Input type={form.periodType === 'YEARLY' ? 'number' : 'month'} value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder={form.periodType === 'YEARLY' ? '如 2026' : undefined} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} />
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
</div>
<div>
<Label></Label>
<Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option>
<option value="B">B</option>
@@ -228,7 +251,7 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
</div>
</div>
<div>
<Label></Label>
<Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
<option value="EXCELLENT"></option>
<option value="QUALIFIED"></option>
@@ -1,14 +1,29 @@
/**
* 医疗期计算器
* 根据员工工龄和地区计算法定医疗期天数
* 法律依据:《企业职工患病或非因工负伤医疗期规定》(劳部发[1994]479号
* 上海特殊规定:沪府发[2015]40号
* 根据员工工龄和地区政策计算法定医疗期天数
* 支持自定义地区政策(数据驱动
*/
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Calculator, HeartPulse, Info } from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { settingsApi } from '../../lib/api-services'
interface PolicyRule {
maxYears: number
months: number
cycleMonths: number
}
interface MedicalPeriodPolicy {
id: string
region: string
legalBasis: string
rules: PolicyRule[]
isDefault: boolean
}
interface MedicalPeriodResult {
totalMonths: number
@@ -19,76 +34,24 @@ interface MedicalPeriodResult {
notes: string[]
}
/**
* 计算医疗期
* @param workYears 本单位工作年限
* @param region 地区(上海/全国)
* @param sickDays 累计病休天数
* @param startDate 开始病休日期
*/
function calculateMedicalPeriod(
workYears: number,
region: 'shanghai' | 'national',
policy: MedicalPeriodPolicy,
sickDays: number,
startDate: string,
): MedicalPeriodResult | null {
if (!startDate || workYears < 0) return null
if (!startDate || workYears < 0 || !policy.rules.length) return null
let totalMonths: number
let cumulativeDays: number
let legalBasis: string
const rule = policy.rules.find(r => workYears < r.maxYears) || policy.rules[policy.rules.length - 1]
const totalMonths = rule.months
const cumulativeDays = rule.cycleMonths * 30
const legalBasis = policy.legalBasis
const notes: string[] = []
if (region === 'shanghai') {
// 上海特殊规定:直接按工龄分档
if (workYears < 1) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月周期
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 4) {
totalMonths = 3
cumulativeDays = 6 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
} else {
totalMonths = 9
cumulativeDays = 18 * 30
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
}
notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算')
} else {
// 全国通用规定:劳部发[1994]479号
if (workYears < 5) {
totalMonths = 3
cumulativeDays = 6 * 30 // 6个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 10) {
totalMonths = 6
cumulativeDays = 12 * 30 // 12个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 15) {
totalMonths = 9
cumulativeDays = 15 * 30 // 15个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else if (workYears < 20) {
totalMonths = 12
cumulativeDays = 18 * 30 // 18个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
} else {
totalMonths = 24
cumulativeDays = 30 * 30 // 30个月内累计病休
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
}
notes.push(`${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
}
notes.push(`${rule.cycleMonths} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
// 计算实际可用天数
const actualDays = Math.max(0, totalMonths * 30 - sickDays)
// 计算医疗期结束日期
const start = new Date(startDate)
const endDate = new Date(start)
endDate.setMonth(endDate.getMonth() + totalMonths)
@@ -110,21 +73,33 @@ function calculateMedicalPeriod(
* 医疗期计算器页面
*/
export default function MedicalPeriodCalculator() {
const [region, setRegion] = useState<'national' | 'shanghai'>('national')
const [selectedPolicyId, setSelectedPolicyId] = useState('')
const [workYears, setWorkYears] = useState('')
const [sickDays, setSickDays] = useState('0')
const [startDate, setStartDate] = useState('')
const [result, setResult] = useState<MedicalPeriodResult | null>(null)
const { data: policies = [] } = useQuery<MedicalPeriodPolicy[]>({
queryKey: ['medical-period-policies'],
queryFn: () => settingsApi.medicalPeriodPolicies(),
})
const selectedPolicy = useMemo(() => {
if (!policies.length) return null
if (selectedPolicyId) return policies.find(p => p.id === selectedPolicyId) || null
return policies.find(p => p.isDefault) || policies[0]
}, [policies, selectedPolicyId])
const handleCalculate = () => {
const years = parseFloat(workYears) || 0
const days = parseInt(sickDays) || 0
const r = calculateMedicalPeriod(years, region, days, startDate)
if (!selectedPolicy) return
const r = calculateMedicalPeriod(years, selectedPolicy, days, startDate)
setResult(r)
}
const handleReset = () => {
setRegion('national')
setSelectedPolicyId('')
setWorkYears('')
setSickDays('0')
setStartDate('')
@@ -144,12 +119,13 @@ export default function MedicalPeriodCalculator() {
<div>
<label className="block text-xs font-medium text-gray-700 mb-1"></label>
<select
value={region}
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
value={selectedPolicy?.id || ''}
onChange={(e) => setSelectedPolicyId(e.target.value)}
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="national"></option>
<option value="shanghai"></option>
{policies.map(p => (
<option key={p.id} value={p.id}>{p.region}{p.isDefault ? '(默认)' : ''}</option>
))}
</select>
</div>
@@ -252,28 +228,36 @@ export default function MedicalPeriodCalculator() {
</Card>
)}
{/* 工龄分档表 */}
<Card>
<h2 className="text-sm font-medium mb-2"></h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody className="divide-y">
<tr><td className="py-2 pr-3"> 5 </td><td className="py-2 pr-3">3 </td><td className="py-2 pr-3">6 </td></tr>
<tr><td className="py-2 pr-3">5-10 </td><td className="py-2 pr-3">6 </td><td className="py-2 pr-3">12 </td></tr>
<tr><td className="py-2 pr-3">10-15 </td><td className="py-2 pr-3">9 </td><td className="py-2 pr-3">15 </td></tr>
<tr><td className="py-2 pr-3">15-20 </td><td className="py-2 pr-3">12 </td><td className="py-2 pr-3">18 </td></tr>
<tr><td className="py-2 pr-3">20 </td><td className="py-2 pr-3">24 </td><td className="py-2 pr-3">30 </td></tr>
</tbody>
</table>
</div>
</Card>
{/* 当前政策分档表 */}
{selectedPolicy && (
<Card>
<h2 className="text-sm font-medium mb-2">{selectedPolicy.region}</h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody className="divide-y">
{selectedPolicy.rules.map((rule, idx) => {
const prevMax = idx > 0 ? selectedPolicy.rules[idx - 1].maxYears : 0
const isLast = idx === selectedPolicy.rules.length - 1
return (
<tr key={idx}>
<td className="py-2 pr-3">{isLast ? `${prevMax} 年以上` : `${prevMax}-${rule.maxYears}`}</td>
<td className="py-2 pr-3">{rule.months} </td>
<td className="py-2 pr-3">{rule.cycleMonths} </td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
</div>
)
}