feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+236
View File
@@ -0,0 +1,236 @@
import { useState, useRef } from "react"
import { toast } from "sonner"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import api from "../../lib/api"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw, History } from "lucide-react"
import { fmt } from "./shared"
export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
const queryClient = useQueryClient()
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: '', electronicContractNo: '', electronicContractUrl: '' })
const contractFileRef = useRef<HTMLInputElement>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 文件类型校验
const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic']
const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic']
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) {
toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
return
}
// 文件大小校验(10MB
const maxSize = 10 * 1024 * 1024
if (file.size > maxSize) {
const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB`
toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)}`)
return
}
const reader = new FileReader()
reader.onload = (event) => {
setForm({ ...form, attachmentUrl: event.target?.result as string })
}
reader.readAsDataURL(file)
}
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
// 按劳动合同法自动判断续签和合同类型建议
const contractAdvice = (() => {
if (!contracts?.length) return null
const fixedContracts = contracts.filter((c: any) => c.contractType === 'FIXED')
const latestContract = contracts[0]
const isRenewal = !!latestContract?.endDate
const renewalCount = (latestContract?.renewalCount || 0)
// 连续订立二次固定期限劳动合同,第三次应订立无固定期限
const shouldUnfixed = fixedContracts.length >= 2
// 连续工作满十年
const yearsSinceHire = hireDate ? (Date.now() - new Date(hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000) : 0
const shouldUnfixedByTenure = yearsSinceHire >= 10
if (shouldUnfixed || shouldUnfixedByTenure) {
return {
isRenewal,
renewalCount: isRenewal ? renewalCount + 1 : renewalCount,
suggestedType: 'UNFIXED',
reason: shouldUnfixed
? `已连续签订${fixedContracts.length}次固定期限合同,按《劳动合同法》第十四条应订立无固定期限合同`
: `连续工作满${Math.floor(yearsSinceHire)}年,按《劳动合同法》第十四条应订立无固定期限合同`,
}
}
if (isRenewal) {
return {
isRenewal: true,
renewalCount: renewalCount + 1,
suggestedType: 'FIXED',
reason: `本次为第${renewalCount + 1}次续签`,
}
}
return null
})()
const handleShowForm = () => {
if (contractAdvice?.suggestedType) {
setForm({
...form,
contractType: contractAdvice.suggestedType,
signDate: new Date().toISOString().slice(0, 10),
startDate: contractAdvice.isRenewal && contracts[0]?.endDate
? contracts[0].endDate.toString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
endDate: '',
probationMonths: 0,
probationSalary: 0,
signMethod: 'PAPER',
attachmentUrl: '',
electronicContractNo: '',
electronicContractUrl: '',
})
}
setShowForm(!showForm)
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h2 className="text-xs font-medium">{contracts?.length || 0}</h2>
<Button size="sm" onClick={handleShowForm}></Button>
</div>
{contractAdvice && !showForm && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}</span>
</div>
)}
{showForm && (
<Card>
{contractAdvice && (
<div className="px-3 py-2 mb-3 rounded-md bg-amber-50 text-amber-700 text-xs flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{contractAdvice.reason}</span>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
<div><Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
</Select>
</div>
<div><Label></Label><Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} /></div>
<div><Label> *</Label><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></div>
{form.contractType === 'FIXED' && (
<div><Label></Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
)}
{form.contractType === 'FIXED' && !contractAdvice?.isRenewal && (
<>
<div><Label>()</Label><Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} /></div>
<div><Label></Label><Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} /></div>
</>
)}
<div className="md:col-span-2 border-t pt-3">
<Label></Label>
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
<option value="PAPER"></option>
<option value="ELECTRONIC"></option>
</Select>
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
<Paperclip className="w-4 h-4 mr-1" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
</div>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
<>
<div><Label> *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
<div><Label> *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
</>
)}
<div className="md:col-span-2 flex gap-2">
<Button onClick={() => addContractMutation.mutate(form)} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
</Button>
<Button variant="secondary" onClick={() => setShowForm(false)}></Button>
</div>
</div>
</Card>
)}
{!contracts?.length ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : contracts.map((c) => (
<Card key={c.id}>
<div className="flex items-start justify-between">
<div className="grid md:grid-cols-3 gap-x-6 gap-y-3 flex-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{typeMap[c.contractType] || c.contractType}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.startDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.contractYears}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.probationMonths}¥{c.probationSalary}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
{c.attachmentUrl ? (
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<Paperclip className="w-3 h-3" />
</a>
) : (
<span className="text-gray-400"></span>
)}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
<>
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500"></span><span className="font-medium">{c.electronicContractNo}</span></div>}
{c.electronicContractUrl && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<FileText className="w-3 h-3" />
</a>
</div>
)}
</>
)}
</div>
</div>
</Card>
))}
</div>
)
}