a2e9ba55c2
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
322 lines
15 KiB
TypeScript
322 lines
15 KiB
TypeScript
import { useState } 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 } from 'lucide-react'
|
||
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 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>
|
||
<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 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>
|
||
)
|
||
}
|