feat: 全部列表页统一分页 + WorkProcess集成现有服务

- WorkProcess/Termination/Evidence/Contracts/Policies/Templates 添加 Pagination 组件
- 后端 getDrafts/getPolicies/enterprise-template 路由添加分页支持(可选参数,向后兼容)
- work-process.service CONFIRM case 修正:移除不存在的 probationEndDate 字段
- 新增 migration_add_three_tables.sql 增量迁移文件
This commit is contained in:
freedakgmail
2026-07-30 10:39:21 +08:00
parent cea7eb07c4
commit 5a5ce7186b
13 changed files with 221 additions and 63 deletions
@@ -0,0 +1,84 @@
-- 增量迁移:添加 WorkProcess、EnterpriseTemplate、AttendancePublish 三张表
-- 不影响现有数据,仅 CREATE TABLE IF NOT EXISTS
-- 执行方式: PGPASSWORD=turbohr2026 psql -U turbohr -d turbohr -f migration_add_three_tables.sql
-- 1. WorkProcess 用工办理工作流
CREATE TABLE IF NOT EXISTS "WorkProcess" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"employeeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"formData" JSONB NOT NULL,
"documents" JSONB,
"approverId" TEXT,
"approvedAt" TIMESTAMP(3),
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkProcess_pkey" PRIMARY KEY ("id")
);
-- 外键:orgId -> Organization
ALTER TABLE "WorkProcess"
ADD CONSTRAINT "WorkProcess_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
-- 外键:employeeId -> Employee (ON DELETE SET NULL)
ALTER TABLE "WorkProcess"
ADD CONSTRAINT "WorkProcess_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL;
-- 索引
CREATE INDEX IF NOT EXISTS "WorkProcess_orgId_status_idx" ON "WorkProcess"("orgId", "status");
CREATE INDEX IF NOT EXISTS "WorkProcess_orgId_type_idx" ON "WorkProcess"("orgId", "type");
CREATE INDEX IF NOT EXISTS "WorkProcess_orgId_createdBy_idx" ON "WorkProcess"("orgId", "createdBy");
-- 2. EnterpriseTemplate 企业自建文本模板
CREATE TABLE IF NOT EXISTS "EnterpriseTemplate" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"description" TEXT,
"content" TEXT NOT NULL,
"variables" TEXT[] NOT NULL DEFAULT '{}',
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EnterpriseTemplate_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "EnterpriseTemplate"
ADD CONSTRAINT "EnterpriseTemplate_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS "EnterpriseTemplate_orgId_category_idx" ON "EnterpriseTemplate"("orgId", "category");
-- 3. AttendancePublish 考勤发布记录
CREATE TABLE IF NOT EXISTS "AttendancePublish" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PUBLISHED',
"publishDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendancePublish_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AttendancePublish"
ADD CONSTRAINT "AttendancePublish_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
-- 唯一约束:同一组织同月只能有一条发布记录
CREATE UNIQUE INDEX IF NOT EXISTS "AttendancePublish_orgId_month_key" ON "AttendancePublish"("orgId", "month");
CREATE INDEX IF NOT EXISTS "AttendancePublish_orgId_month_idx" ON "AttendancePublish"("orgId", "month");
@@ -15,14 +15,21 @@ function extractVariables(content: string): string[] {
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { category, search } = req.query
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const where: any = { orgId: req.user!.orgId, status: 'ACTIVE' }
if (category) where.category = category
if (search) where.name = { contains: String(search) }
const items = await (prisma as any).enterpriseTemplate.findMany({
where,
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: items })
const [items, total] = await Promise.all([
(prisma as any).enterpriseTemplate.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
(prisma as any).enterpriseTemplate.count({ where }),
])
res.json({ success: true, data: { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
} catch (err) {
next(err)
}
+4 -2
View File
@@ -13,8 +13,10 @@ const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const policies = await getPolicies(req.user!.orgId, status)
res.json({ success: true, data: policies })
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const result = await getPolicies(req.user!.orgId, status, page, pageSize)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
+3 -1
View File
@@ -162,7 +162,9 @@ router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
const status = req.query.status as string | undefined
const search = req.query.search as string | undefined
const department = req.query.department as string | undefined
const result = await getDrafts(req.user!.orgId, status, search, department)
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const result = await getDrafts(req.user!.orgId, status, search, department, page, pageSize)
res.json({ success: true, data: result })
} catch (err) {
next(err)
+10 -3
View File
@@ -73,24 +73,31 @@ export async function createPolicy(orgId: string, userId: string, data: { title:
/**
* 获取制度列表(含阅读签收统计)
*/
export async function getPolicies(orgId: string, status?: string) {
export async function getPolicies(orgId: string, status?: string, page?: number, pageSize?: number) {
const where: any = { orgId }
if (status) where.status = status
const [policies, totalEmployees] = await Promise.all([
const hasPagination = page && pageSize
const [policies, totalEmployees, total] = await Promise.all([
prisma.policyDocument.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: {
_count: { select: { readRecords: true } },
},
...(hasPagination ? { skip: (page! - 1) * pageSize!, take: pageSize! } : {}),
}),
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
hasPagination ? prisma.policyDocument.count({ where }) : Promise.resolve(0),
])
return policies.map(p => ({
const items = policies.map(p => ({
...p,
readCount: p._count?.readRecords || 0,
totalEmployees,
}))
if (hasPagination) {
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize!) }
}
return items
}
/**
+17 -7
View File
@@ -738,7 +738,7 @@ export async function cancelTermination(orgId: string, recordId: string, userId:
}
/** 获取草稿列表 */
export async function getDrafts(orgId: string, status?: string, search?: string, department?: string) {
export async function getDrafts(orgId: string, status?: string, search?: string, department?: string, page?: number, pageSize?: number) {
const where: any = { orgId }
if (status) {
where.status = status
@@ -758,13 +758,18 @@ export async function getDrafts(orgId: string, status?: string, search?: string,
}
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
const hasPagination = page && pageSize
const [records, total] = await Promise.all([
prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
...(hasPagination ? { skip: (page! - 1) * pageSize!, take: pageSize! } : {}),
}),
hasPagination ? prisma.terminationRecord.count({ where }) : Promise.resolve(0),
])
return records.map((r) => ({
const items = records.map((r) => ({
id: r.id,
employeeId: r.employeeId,
employeeName: r.employee.name,
@@ -780,6 +785,11 @@ export async function getDrafts(orgId: string, status?: string, search?: string,
createdAt: r.createdAt.toISOString().slice(0, 10),
updatedAt: r.updatedAt.toISOString().slice(0, 10),
}))
if (hasPagination) {
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize!) }
}
return items
}
/** 获取单条记录详情(含所有流程字段) */
+1 -8
View File
@@ -69,17 +69,10 @@ export async function executeWorkProcess(processId: string, type: string, formDa
case 'CONFIRM': {
const { employeeId, confirmDate, regularSalary } = formData
if (employeeId) {
const updateData: any = {}
if (regularSalary) {
updateData.monthlySalary = encrypt(String(regularSalary))
}
if (confirmDate) {
updateData.probationEndDate = new Date(confirmDate)
}
if (Object.keys(updateData).length > 0) {
await prisma.employee.update({
where: { id: employeeId },
data: updateData,
data: { monthlySalary: encrypt(String(regularSalary)) },
})
await runRiskDetection(orgId)
}
+11 -19
View File
@@ -8,6 +8,7 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
interface EmployeeItem {
id: string
@@ -37,13 +38,14 @@ export default function Contracts() {
const [filterDepartment, setFilterDepartment] = useState('')
const [filterContractStatus, setFilterContractStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
const { data, isLoading } = useQuery<EmployeeListResponse>({
queryKey: ['employees', search, filterDepartment, filterContractStatus, page],
queryKey: ['employees', search, filterDepartment, filterContractStatus, page, pageSize],
queryFn: async () => {
const params: any = { search, page, pageSize: 20 }
const params: any = { search, page, pageSize }
if (filterDepartment) params.department = filterDepartment
if (filterContractStatus) params.contractStatus = filterContractStatus
const res = await api.get('/roster', { params }) as any
@@ -181,23 +183,13 @@ export default function Contracts() {
</div>
{/* 分页 */}
{data.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<Button
variant="secondary"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
></Button>
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
<Button
variant="secondary"
size="sm"
disabled={page === data.totalPages}
onClick={() => setPage(p => p + 1)}
></Button>
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={data.total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</>
)}
</Card>
+19 -6
View File
@@ -4,21 +4,27 @@ import { ShieldCheck, FileText, AlertCircle, CheckCircle, XCircle } from 'lucide
import api from '../lib/api'
import Card from '../components/ui/Card'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
/**
* 证据链管理页面
*/
export default function Evidence() {
const [refType, setRefType] = useState<string>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['evidence', refType],
const { data: listData, isLoading } = useQuery<any>({
queryKey: ['evidence', refType, page, pageSize],
queryFn: async () => {
const params = refType ? `?category=${refType}` : '?category=ALL'
const res = await api.get(`/evidence${params}`) as any
return res.data?.records || []
const params: any = { page, pageSize }
params.category = refType || 'ALL'
const res = await api.get('/evidence', { params }) as any
return res.data
},
})
const list = listData?.records || []
const total = listData?.total || 0
const { data: verifyResult, refetch: verifyAll } = useQuery<any>({
queryKey: ['evidence-verify-all'],
@@ -68,7 +74,7 @@ export default function Evidence() {
{['', 'ONBOARD', 'CONTRACT_SIGN', 'PAYSLIP_CONFIRM', 'DISCIPLINARY', 'ATTENDANCE', 'TERMINATION'].map((t) => (
<button
key={t}
onClick={() => setRefType(t)}
onClick={() => { setRefType(t); setPage(1) }}
className={`px-3 py-1 text-xs rounded-lg ${refType === t ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
{t === '' ? '全部' : t === 'DISCIPLINARY' ? '违纪' : t === 'TERMINATION' ? '解聘' : t === 'CONTRACT_SIGN' ? '合同' : t === 'PAYSLIP_CONFIRM' ? '薪酬' : t === 'ONBOARD' ? '入职' : '考勤'}
@@ -112,6 +118,13 @@ export default function Evidence() {
))}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</div>
)
}
+15 -3
View File
@@ -6,6 +6,7 @@ import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const STEP_LABELS: Record<string, string> = {
DRAFTING: '起草',
@@ -23,14 +24,18 @@ export default function Policies() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState<any>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: list, isLoading } = useQuery<any>({
queryKey: ['policies'],
const { data: listData, isLoading } = useQuery<any>({
queryKey: ['policies', page, pageSize],
queryFn: async () => {
const res = await api.get('/policies') as any
const res = await api.get('/policies', { params: { page, pageSize } }) as any
return res.data
},
})
const list = listData?.items || []
const total = listData?.total || 0
const advanceMutation = useMutation({
mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }),
@@ -114,6 +119,13 @@ export default function Policies() {
))}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
{/* 详情弹窗 */}
{selectedPolicy && (
+17 -4
View File
@@ -9,6 +9,7 @@ import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
const CATEGORY_LABELS: Record<string, string> = {
CONTRACT: '合同',
@@ -303,6 +304,8 @@ function SystemTemplates() {
function EnterpriseTemplates() {
const queryClient = useQueryClient()
const [category, setCategory] = useState<string>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [showEdit, setShowEdit] = useState(false)
const [editItem, setEditItem] = useState<any>(null)
const [form, setForm] = useState({ name: '', category: 'CONTRACT', description: '', content: '' })
@@ -310,14 +313,17 @@ function EnterpriseTemplates() {
const [rendered, setRendered] = useState('')
const [variables, setVariables] = useState<Record<string, string>>({})
const { data: list, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates', category],
const { data: listData, isLoading } = useQuery<any>({
queryKey: ['enterprise-templates', category, page, pageSize],
queryFn: async () => {
const params = category ? `?category=${category}` : ''
const res = await api.get(`/enterprise-templates${params}`) as any
const params: any = { page, pageSize }
if (category) params.category = category
const res = await api.get('/enterprise-templates', { params }) as any
return res.data
},
})
const list = listData?.items || []
const total = listData?.total || 0
const { data: detail } = useQuery<any>({
queryKey: ['enterprise-template-detail', selected?.id],
@@ -459,6 +465,13 @@ function EnterpriseTemplates() {
))}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
{/* 编辑弹窗 */}
<Modal open={showEdit} onClose={() => setShowEdit(false)} title={editItem ? '编辑模板' : '新建模板'} size="lg">
+15 -3
View File
@@ -9,6 +9,7 @@ import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -119,6 +120,8 @@ export default function Termination() {
const [filterStatus, setFilterStatus] = useState('')
const [filterDepartment, setFilterDepartment] = useState('')
const [searchTerm, setSearchTerm] = useState('')
const [draftPage, setDraftPage] = useState(1)
const [draftPageSize, setDraftPageSize] = useState(20)
const { data: employees } = useQuery<RosterEmployee[]>({
queryKey: ['roster-for-termination'],
@@ -250,10 +253,10 @@ export default function Termination() {
})
// 草稿列表
const { data: drafts, refetch: refetchDrafts } = useQuery({
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm],
const { data: draftsData, refetch: refetchDrafts } = useQuery({
queryKey: ['termination-drafts', filterStatus, filterDepartment, searchTerm, draftPage, draftPageSize],
queryFn: async () => {
const params: any = {}
const params: any = { page: draftPage, pageSize: draftPageSize }
if (filterStatus) params.status = filterStatus
if (filterDepartment) params.department = filterDepartment
if (searchTerm) params.search = searchTerm
@@ -262,6 +265,8 @@ export default function Termination() {
},
enabled: view === 'list',
})
const drafts = draftsData?.items || []
const draftsTotal = draftsData?.total || 0
// 草稿详情
const { data: draftDetail } = useQuery({
@@ -823,6 +828,13 @@ export default function Termination() {
</table>
</div>
)}
<Pagination
page={draftPage}
pageSize={draftPageSize}
total={draftsTotal}
onPageChange={setDraftPage}
onPageSizeChange={(s) => { setDraftPageSize(s); setDraftPage(1) }}
/>
</Card>
</>
)}
+13 -2
View File
@@ -11,6 +11,7 @@ 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 Pagination from '../components/ui/Pagination'
const PROCESS_ICONS: Record<string, any> = {
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
@@ -145,11 +146,13 @@ export default function WorkProcess() {
const [filterStatus, setFilterStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [previewContent, setPreviewContent] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data: listData, isLoading } = useQuery({
queryKey: ['work-processes', filterType, filterStatus],
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
queryFn: async () => {
const params: any = {}
const params: any = { page, pageSize }
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
const res = await api.get('/work-processes', { params }) as any
@@ -238,6 +241,7 @@ export default function WorkProcess() {
}
const items = listData?.items || []
const total = listData?.total || 0
return (
<div className="space-y-4">
@@ -325,6 +329,13 @@ export default function WorkProcess() {
})}
</div>
)}
<Pagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
/>
</Card>
{/* 创建/编辑弹窗 */}