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:
@@ -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-MM(null=至今有效)
|
||||
reason String? // 调部门原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
|
||||
reason String? // 调动原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调动
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user