Files
TurboHR/frontend/src/pages/Policies.tsx
T
selfrelease d79e3baa34 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
2026-07-26 20:32:38 +08:00

274 lines
13 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 } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
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 { data: list, isLoading } = useQuery<any>({
queryKey: ['policies'],
queryFn: async () => {
const res = await api.get('/policies') as any
return res.data
},
})
const advanceMutation = useMutation({
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }),
onSuccess: () => {
toast.success('流程步骤已推进')
queryClient.invalidateQueries({ queryKey: ['policies'] })
// 刷新选中制度详情
if (selectedPolicy) {
api.get(`/policies/${selectedPolicy.id}`).then((res: any) => setSelectedPolicy(res.data))
}
},
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
})
return (
<div className="space-y-3">
<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>
) : !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>
)}
{/* 详情弹窗 */}
{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 createMutation = useMutation({
mutationFn: () => api.post('/policies', { 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>
<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="输入制度正文..." />
</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 { data, isLoading } = useQuery<any>({
queryKey: ['policy-read-stats', policyId],
queryFn: async () => {
const res = await api.get(`/policies/${policyId}/read-stats`) as any
return res.data
},
})
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="text-xs text-amber-600 mb-2">
{data.unreadCount}
</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>
)
}