feat: 调部门改为调动,使用组织架构部门和职务下拉选择

- 后端:EmployeeDepartmentRecord 增加 oldPosition/newPosition 字段
- 后端:department-change 接口支持 departmentId 关联 + position 职务变动
- 后端:同步更新 Employee.departmentId 和 Employee.position
- 前端:DeptChangeModal 改为从组织架构下拉选择目标部门(树形)和职务
- 前端:选择部门后自动过滤该部门下的职务
- 前端:按钮文案从"调部门"改为"调动"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 15:00:38 +08:00
parent e8470e7bb4
commit e3d46d9142
4 changed files with 91 additions and 25 deletions
+4 -2
View File
@@ -914,10 +914,12 @@ model EmployeeDepartmentRecord {
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
oldPosition String? // 调整前职务
newPosition String? // 调整后职务
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
reason String? // 调原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调
createdBy String
createdAt DateTime @default(now())
+24 -7
View File
@@ -1442,10 +1442,10 @@ router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res,
} catch (err) { next(err) }
})
// 调部门
// 调动(部门+职务变动)
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newDepartment, effectiveMonth, reason } = req.body
const { newDepartment, newPosition, departmentId, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
@@ -1454,22 +1454,35 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
}
const oldDepartment = employee.department
const oldPosition = employee.position || null
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 校验 departmentId 是否属于当前组织
let deptName = newDepartment
if (departmentId) {
const dept = await prisma.department.findFirst({ where: { id: departmentId, orgId: req.user!.orgId } })
if (!dept) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '目标部门不存在' } })
}
deptName = dept.name
}
// 关闭之前有效记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新部门记录
// 创建新调动记录
const record = await prisma.employeeDepartmentRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldDepartment,
newDepartment,
newDepartment: deptName,
oldPosition,
newPosition: newPosition || null,
effectiveMonth: effMonth,
endMonth: null,
changeType: 'TRANSFER',
@@ -1478,13 +1491,17 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
},
})
// 同步 Employee 便捷字段
// 同步 Employee 字段department 文本 + departmentId 关联 + position 职务)
await prisma.employee.update({
where: { id: req.params.id },
data: { department: newDepartment },
data: {
department: deptName,
departmentId: departmentId || null,
position: newPosition || employee.position,
},
})
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
+2 -2
View File
@@ -874,8 +874,8 @@ export default function Roster() {
)}
<button
type="button"
title="调部门"
aria-label={`${e.name}整部门`}
title="调"
aria-label={`${e.name}`}
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
onClick={(ev) => {
ev.stopPropagation()
+61 -14
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
import { useQuery } from "@tanstack/react-query"
import { toast } from "sonner"
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import Button from "../../components/ui/Button"
import { Input, Label, Select } from "../../components/ui/Input"
import Modal from "../../components/ui/Modal"
@@ -83,23 +84,55 @@ export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }:
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({
newDepartment: employee.department || '',
departmentId: employee.departmentId || '',
newPosition: employee.position || '',
effectiveDate: todayStr,
reason: '',
})
// 拉取组织架构部门和职务列表
const { data: departments = [] } = useQuery({
queryKey: ['departments'],
queryFn: () => api.get('/departments').then(r => r.data),
})
const { data: positions = [] } = useQuery({
queryKey: ['positions'],
queryFn: () => api.get('/positions').then(r => r.data),
})
// 构建部门树形下拉选项(带层级缩进)
const deptOptions: { id: string; label: string; level: number }[] = []
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
deptOptions.push({ id: d.id, label: d.name, level })
buildDeptOptions(items, d.id, level + 1)
})
}
buildDeptOptions(departments, null, 0)
// 选中新部门后,过滤该部门下的职务(如果职务有 departmentId 关联)
const filteredPositions = form.departmentId
? positions.filter((p: any) => !p.departmentId || p.departmentId === form.departmentId)
: positions
const selectedDept = departments.find((d: any) => d.id === form.departmentId)
const hasChange = (form.departmentId && form.departmentId !== employee.departmentId) ||
(form.newPosition && form.newPosition !== employee.position)
const handleSubmit = () => {
onSubmit({
newDepartment: form.newDepartment,
effectiveDate: new Date(form.effectiveDate).toISOString(),
departmentId: form.departmentId || undefined,
newDepartment: selectedDept?.name || employee.department,
newPosition: form.newPosition || undefined,
effectiveMonth: form.effectiveDate.slice(0, 7),
reason: form.reason || undefined,
})
}
const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department
const canSubmit = form.effectiveDate && hasChange
return (
<Modal open onClose={onClose} title={`部门 - ${employee.name}`}>
<Modal open onClose={onClose} title={` - ${employee.name}`}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
@@ -107,23 +140,37 @@ export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }:
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
</div>
<div>
<Label></Label>
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
<Label> / </Label>
<div className="text-xs text-gray-600 py-1.5">{employee.department} / {employee.position || '—'}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.newDepartment} onChange={(e) => setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" />
<Label> *</Label>
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
<option value=""></option>
{deptOptions.map(d => (
<option key={d.id} value={d.id}>{' '.repeat(d.level)}{d.label}</option>
))}
</Select>
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
<Label></Label>
<Select value={form.newPosition} onChange={(e) => setForm({ ...form, newPosition: e.target.value })}>
<option value=""></option>
{filteredPositions.map((p: any) => (
<option key={p.id} value={p.name}>{p.name}{p.level ? `${p.level}` : ''}</option>
))}
</Select>
</div>
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" />
<Label> *</Label>
<Input type="date" value={form.effectiveDate} onChange={(e) => setForm({ ...form, effectiveDate: e.target.value })} />
</div>
<div>
<Label></Label>
<Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整、岗位轮换" />
</div>
{error && (
<div className="text-xs text-danger">
@@ -132,7 +179,7 @@ export function DeptChangeModal({ employee, onClose, onSubmit, loading, error }:
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调部门'}</Button>
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '确认调'}</Button>
</div>
</div>
</Modal>