Files
TurboHR/frontend/src/pages/Policies.tsx
T
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页
- 问题2: 离职证明模板支持自定义+员工端下载
- 问题4(P0): 修复工资填写后数据归零问题
- 问题5: 社保添加员工参保信息列表
- 问题6(P0): 商业保险支持为员工参保
- 问题7(P0): 员工福利支持为员工添加福利
- 问题8: 规章制度支持导入Word文档
- 问题9: 文本模板下载Word增加HTML格式
- 问题10: 模板下载变量替换修复(排除token参数)
- 问题11(P0): 电子签署发起时员工下拉框有选项
- 问题12: 新增绩效记录添加考评人选项
- 问题13: 违纪记录添加处罚执行细节
- 问题14: 特殊员工列表添加查看详情按钮和姓名链接
- 问题15: 员工福利汇总正确显示参保人员
- 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
2026-08-11 22:02:19 +08:00

349 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef } from 'react'
import { usePageSize } from '../hooks/usePageSize'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
import mammoth from 'mammoth'
import { policiesApi } from '../lib/api-services'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import PageGuide from '../components/ui/PageGuide'
import QueryError from '../components/ui/QueryError'
const STEP_LABELS: Record<string, string> = {
DRAFTING: '起草',
DISCUSSION: '讨论',
CONSULTATION: '协商',
PUBLICATION: '公示',
}
const STEP_ORDER = ['DRAFTING', 'DISCUSSION', 'CONSULTATION', 'PUBLICATION']
/**
* 规章制度民主程序管理页面
*/
export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const { data: listData, isLoading, isError, error, refetch } = useQuery<any>({
queryKey: ['policies', page, pageSize],
queryFn: async () => {
return await policiesApi.list({ page, pageSize })
},
})
const list = listData?.items || []
const total = listData?.total || 0
const advanceMutation = useMutation({
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => policiesApi.advanceStep(id, step, note),
onSuccess: () => {
toast.success('流程步骤已推进')
queryClient.invalidateQueries({ queryKey: ['policies'] })
// 刷新选中制度详情
if (selectedPolicy) {
policiesApi.detail(selectedPolicy.id).then((res: any) => setSelectedPolicy(res))
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
return (
<div className="space-y-3">
<PageGuide>
</PageGuide>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500"> </p>
</div>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-500">...</div>
) : isError ? (
<QueryError error={error} onRetry={refetch} />
) : !list || list.length === 0 ? (
<EmptyState title="暂无规章制度" description="点击右上角新建制度" />
) : (
<div className="space-y-2">
{list.map((p: any) => (
<Card key={p.id} className="hover:shadow-md transition-shadow cursor-pointer" >
<div onClick={() => setSelectedPolicy(p)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{p.title}</span>
{p.status === 'PUBLISHED' ? (
<span className="px-1.5 py-0.5 rounded text-xs bg-green-100 text-green-700"></span>
) : (
<span className="px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-700">{STEP_LABELS[STEP_ORDER[(p.democracyProgress?.currentStep || 1) - 1]] || '起草中'}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1 truncate">{p.content?.slice(0, 80) || '暂无内容'}</div>
<div className="flex items-center gap-1 mt-2">
{STEP_ORDER.map((step, i) => {
const currentStep = p.democracyProgress?.currentStep || 1
const isDone = i < currentStep - 1 || p.status === 'PUBLISHED'
const isCurrent = i === currentStep - 1 && p.status !== 'PUBLISHED'
return (
<div key={step} className="flex items-center">
<div className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs ${
isDone ? 'bg-green-100 text-green-700' : isCurrent ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-400'
}`}>
{isDone ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{STEP_LABELS[step]}
</div>
{i < STEP_ORDER.length - 1 && <ChevronRight className="w-3 h-3 text-gray-300" />}
</div>
)
})}
</div>
{p.status === 'PUBLISHED' && p.totalEmployees > 0 && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-gray-500"></span>
<div className="flex-1 bg-gray-100 rounded-full h-1.5 overflow-hidden max-w-32">
<div className="bg-green-500 h-full rounded-full" style={{ width: `${p.totalEmployees > 0 ? Math.round((p.readCount / p.totalEmployees) * 100) : 0}%` }} />
</div>
<span className="text-gray-600">{p.readCount}/{p.totalEmployees}</span>
</div>
)}
</div>
</div>
</div>
</Card>
))}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={() => setPage(1)}
/>
{/* 详情弹窗 */}
{selectedPolicy && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={() => setSelectedPolicy(null)}>
<Card className="max-w-2xl w-full max-h-[80vh] overflow-y-auto" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selectedPolicy.title}</h2>
<button onClick={() => setSelectedPolicy(null)} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="text-sm text-gray-600 whitespace-pre-wrap mb-4">{selectedPolicy.content || '暂无内容'}</div>
<div className="space-y-2 border-t pt-3">
<div className="text-xs font-medium text-gray-600"></div>
{(selectedPolicy.democracyProgress?.steps || []).map((s: any, i: number) => {
const isDone = s.status === 'COMPLETED' || (selectedPolicy.status === 'PUBLISHED')
const isCurrent = s.status === 'IN_PROGRESS' && selectedPolicy.status !== 'PUBLISHED'
return (
<div key={i} className={`p-2 rounded-lg ${isDone ? 'bg-green-50' : isCurrent ? 'bg-primary/5' : 'bg-gray-50'}`}>
<div className="flex items-center justify-between">
<span className="text-sm flex items-center gap-1.5">
{isDone ? <CheckCircle className="w-4 h-4 text-green-600" /> : <Clock className="w-4 h-4 text-gray-400" />}
{s.name}
<span className="text-xs text-gray-400 font-normal">· {s.description}</span>
</span>
{isCurrent && (
<Button
size="sm"
onClick={() => {
const note = window.prompt('请输入本步骤备注(可选)') || ''
advanceMutation.mutate({ id: selectedPolicy.id, step: i + 1, note })
}}
>
</Button>
)}
</div>
{s.date && (
<div className="text-xs text-gray-500 mt-1">
{s.status === 'COMPLETED' ? '完成时间' : '开始时间'}{s.date}
{s.note && <span className="ml-2 text-gray-400">{s.note}</span>}
</div>
)}
</div>
)
})}
</div>
{/* 阅读签收统计(仅已公示制度显示) */}
{selectedPolicy.status === 'PUBLISHED' && (
<ReadStats policyId={selectedPolicy.id} />
)}
</div>
</Card>
</div>
)}
{/* 新建弹窗 */}
{showCreate && (
<CreatePolicyModal onClose={() => setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['policies'] }) }} />
)}
</div>
)
}
function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [type, setType] = useState('RULES')
const fileInputRef = useRef<HTMLInputElement>(null)
const createMutation = useMutation({
mutationFn: () => policiesApi.create({ title, content, type }),
onSuccess: () => { toast.success('制度已创建'); onSuccess() },
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="max-w-lg w-full" >
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium"></h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-600"></label>
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="如:考勤管理制度" />
</div>
<div>
<label className="text-xs text-gray-600"></label>
<select value={type} onChange={e => setType(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
<option value="RULES"></option>
<option value="NOTICE"></option>
</select>
</div>
<div>
<label className="text-xs text-gray-600"></label>
<div className="flex items-center gap-2 mt-1 mb-1">
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
<Upload className="w-3.5 h-3.5 mr-1" /> Word
</Button>
<input
ref={fileInputRef}
type="file"
accept=".docx"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.convertToHtml({ arrayBuffer })
setContent(result.value)
toast.success('文档导入成功')
} catch {
toast.error('文档解析失败,请确保为 .docx 格式')
}
e.target.value = ''
}}
/>
<span className="text-xs text-gray-400"> .docx HTML</span>
</div>
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => createMutation.mutate()} disabled={!title || !content || createMutation.isPending}></Button>
</div>
</div>
</div>
</Card>
</div>
)
}
/**
* 阅读签收统计组件
*/
function ReadStats({ policyId }: { policyId: string }) {
const queryClient = useQueryClient()
const [showUnread, setShowUnread] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
return await policiesApi.readStats(policyId)
},
})
const remindMutation = useMutation({
mutationFn: async (employeeIds?: string[]) => {
return await policiesApi.remind(policyId, employeeIds)
},
onSuccess: (res: any) => {
toast.success(`已催办 ${res?.reminded || 0} 名未签收员工`)
queryClient.invalidateQueries({ queryKey: ['policy-read-stats', policyId] })
},
onError: () => toast.error('催办失败'),
})
if (isLoading) return <div className="text-xs text-gray-400 mt-3">...</div>
if (!data) return null
const percent = data.total > 0 ? Math.round((data.readCount / data.total) * 100) : 0
return (
<div className="mt-4 border-t pt-3">
<div className="text-xs font-medium text-gray-600 mb-2"></div>
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
<div className="bg-green-500 h-full rounded-full transition-all" style={{ width: `${percent}%` }} />
</div>
<span className="text-xs text-gray-600 shrink-0">
{data.readCount}/{data.total} {percent}%
</span>
</div>
{data.unreadCount > 0 && (
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-amber-600">{data.unreadCount} </span>
<button onClick={() => setShowUnread(!showUnread)} className="text-xs text-primary hover:underline">
{showUnread ? '收起' : '查看明细'}
</button>
<Button size="sm" variant="secondary" className="!h-6 !px-2 !text-xs" onClick={() => remindMutation.mutate(undefined)} disabled={remindMutation.isPending}>
<Bell className="w-3 h-3 mr-1" />
</Button>
</div>
)}
{showUnread && data.unreadEmployees && data.unreadEmployees.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1 mb-2">
{data.unreadEmployees.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-amber-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-amber-600"></span>
</div>
))}
</div>
)}
{data.records && data.records.length > 0 && (
<div className="max-h-40 overflow-y-auto space-y-1">
{data.records.map((r: any) => (
<div key={r.employeeId} className="flex items-center justify-between px-2 py-1 rounded bg-gray-50 text-xs">
<span className="text-gray-700">{r.employeeName}</span>
<span className="text-gray-400">{r.department}</span>
<span className="text-gray-400">{r.readAt?.slice(0, 16).replace('T', ' ')}</span>
</div>
))}
</div>
)}
</div>
)
}