d79e3baa34
- 面包屑导航组件,集成至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
274 lines
13 KiB
TypeScript
274 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|